diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7031ca7..754d18b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 240fcf6..f1f1424 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d6705e..295cf40 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 @@ -38,32 +43,32 @@ jobs: - name: Test run: go test -v ./... - + test-ui: name: Test UI runs-on: ubuntu-latest - + defaults: run: working-directory: ui - + strategy: matrix: node-version: [10.x, 14.x] steps: - uses: actions/checkout@v2 - + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - + - name: Install dependencies run: npm install - + - name: Build run: npm run build --if-present - + - name: Test run: npm test diff --git a/Dockerfile b/Dockerfile index 7193bfb..e6bb67a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 297b70c..83cfcf1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/data/models/dlib_face_recognition_resnet_model_v1.dat b/api/data/models/dlib_face_recognition_resnet_model_v1.dat new file mode 100644 index 0000000..ddb5158 Binary files /dev/null and b/api/data/models/dlib_face_recognition_resnet_model_v1.dat differ diff --git a/api/data/models/mmod_human_face_detector.dat b/api/data/models/mmod_human_face_detector.dat new file mode 100644 index 0000000..f112a0a Binary files /dev/null and b/api/data/models/mmod_human_face_detector.dat differ diff --git a/api/data/models/shape_predictor_5_face_landmarks.dat b/api/data/models/shape_predictor_5_face_landmarks.dat new file mode 100644 index 0000000..67878ed Binary files /dev/null and b/api/data/models/shape_predictor_5_face_landmarks.dat differ diff --git a/api/database/database.go b/api/database/database.go index fd84b2a..c699abf 100644 --- a/api/database/database.go +++ b/api/database/database.go @@ -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 diff --git a/api/go.mod b/api/go.mod index 287c57f..a5bff47 100644 --- a/api/go.mod +++ b/api/go.mod @@ -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 diff --git a/api/go.sum b/api/go.sum index 82d0d0c..97b8d72 100644 --- a/api/go.sum +++ b/api/go.sum @@ -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= diff --git a/api/gqlgen.yml b/api/gqlgen.yml index 4d2c574..0ca6f79 100644 --- a/api/gqlgen.yml +++ b/api/gqlgen.yml @@ -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 diff --git a/api/graphql/generated.go b/api/graphql/generated.go index a41d3c4..00734b5 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -39,6 +39,7 @@ type Config struct { type ResolverRoot interface { Album() AlbumResolver + ImageFace() ImageFaceResolver Media() MediaResolver Mutation() MutationResolver Query() QueryResolver @@ -71,10 +72,31 @@ type ComplexityRoot struct { Token func(childComplexity int) int } + FaceGroup struct { + ID func(childComplexity int) int + ImageFaces func(childComplexity int) int + Label func(childComplexity int) int + } + + FaceRectangle struct { + MaxX func(childComplexity int) int + MaxY func(childComplexity int) int + MinX func(childComplexity int) int + MinY func(childComplexity int) int + } + + ImageFace struct { + FaceGroup func(childComplexity int) int + ID func(childComplexity int) int + Media func(childComplexity int) int + Rectangle func(childComplexity int) int + } + Media struct { Album func(childComplexity int) int Downloads func(childComplexity int) int Exif func(childComplexity int) int + Faces func(childComplexity int) int Favorite func(childComplexity int) int HighRes func(childComplexity int) int ID func(childComplexity int) int @@ -116,14 +138,19 @@ type ComplexityRoot struct { Mutation struct { AuthorizeUser func(childComplexity int, username string, password string) int + CombineFaceGroups func(childComplexity int, destinationFaceGroupID int, sourceFaceGroupID int) int CreateUser func(childComplexity int, username string, password *string, admin bool) int DeleteShareToken func(childComplexity int, token string) int DeleteUser func(childComplexity int, id int) int + DetachImageFaces func(childComplexity int, imageFaceIDs []int) int FavoriteMedia func(childComplexity int, mediaID int, favorite bool) int InitialSetupWizard func(childComplexity int, username string, password string, rootPath string) int + MoveImageFaces func(childComplexity int, imageFaceIDs []int, destinationFaceGroupID int) int ProtectShareToken func(childComplexity int, token string, password *string) int + RecognizeUnlabeledFaces func(childComplexity int) int ScanAll func(childComplexity int) int ScanUser func(childComplexity int, userID int) int + SetFaceGroupLabel func(childComplexity int, faceGroupID int, label *string) int SetPeriodicScanInterval func(childComplexity int, interval int) int SetScannerConcurrentWorkers func(childComplexity int, workers int) int ShareAlbum func(childComplexity int, albumID int, expire *time.Time, password *string) int @@ -150,6 +177,7 @@ type ComplexityRoot struct { Media func(childComplexity int, id int) int MediaList func(childComplexity int, ids []int) int MyAlbums func(childComplexity int, order *models.Ordering, paginate *models.Pagination, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) int + MyFaceGroups func(childComplexity int, paginate *models.Pagination) int MyMedia func(childComplexity int, order *models.Ordering, paginate *models.Pagination) int MyMediaGeoJSON func(childComplexity int) int MyTimeline func(childComplexity int, paginate *models.Pagination, onlyFavorites *bool) int @@ -233,6 +261,9 @@ type AlbumResolver interface { Path(ctx context.Context, obj *models.Album) ([]*models.Album, error) Shares(ctx context.Context, obj *models.Album) ([]*models.ShareToken, error) } +type ImageFaceResolver interface { + FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) +} type MediaResolver interface { Thumbnail(ctx context.Context, obj *models.Media) (*models.MediaURL, error) HighRes(ctx context.Context, obj *models.Media) (*models.MediaURL, error) @@ -244,6 +275,7 @@ type MediaResolver interface { Shares(ctx context.Context, obj *models.Media) ([]*models.ShareToken, error) Downloads(ctx context.Context, obj *models.Media) ([]*models.MediaDownload, error) + Faces(ctx context.Context, obj *models.Media) ([]*models.ImageFace, error) } type MutationResolver interface { AuthorizeUser(ctx context.Context, username string, password string) (*models.AuthorizeResult, error) @@ -262,6 +294,11 @@ type MutationResolver interface { UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) + SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) + CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) + MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) + RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) + DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) } type QueryResolver interface { SiteInfo(ctx context.Context) (*models.SiteInfo, error) @@ -278,6 +315,7 @@ type QueryResolver interface { ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) ShareTokenValidatePassword(ctx context.Context, token string, password *string) (bool, error) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) + MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) } type ShareTokenResolver interface { HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) @@ -406,6 +444,83 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AuthorizeResult.Token(childComplexity), true + case "FaceGroup.id": + if e.complexity.FaceGroup.ID == nil { + break + } + + return e.complexity.FaceGroup.ID(childComplexity), true + + case "FaceGroup.imageFaces": + if e.complexity.FaceGroup.ImageFaces == nil { + break + } + + return e.complexity.FaceGroup.ImageFaces(childComplexity), true + + case "FaceGroup.label": + if e.complexity.FaceGroup.Label == nil { + break + } + + return e.complexity.FaceGroup.Label(childComplexity), true + + case "FaceRectangle.maxX": + if e.complexity.FaceRectangle.MaxX == nil { + break + } + + return e.complexity.FaceRectangle.MaxX(childComplexity), true + + case "FaceRectangle.maxY": + if e.complexity.FaceRectangle.MaxY == nil { + break + } + + return e.complexity.FaceRectangle.MaxY(childComplexity), true + + case "FaceRectangle.minX": + if e.complexity.FaceRectangle.MinX == nil { + break + } + + return e.complexity.FaceRectangle.MinX(childComplexity), true + + case "FaceRectangle.minY": + if e.complexity.FaceRectangle.MinY == nil { + break + } + + return e.complexity.FaceRectangle.MinY(childComplexity), true + + case "ImageFace.faceGroup": + if e.complexity.ImageFace.FaceGroup == nil { + break + } + + return e.complexity.ImageFace.FaceGroup(childComplexity), true + + case "ImageFace.id": + if e.complexity.ImageFace.ID == nil { + break + } + + return e.complexity.ImageFace.ID(childComplexity), true + + case "ImageFace.media": + if e.complexity.ImageFace.Media == nil { + break + } + + return e.complexity.ImageFace.Media(childComplexity), true + + case "ImageFace.rectangle": + if e.complexity.ImageFace.Rectangle == nil { + break + } + + return e.complexity.ImageFace.Rectangle(childComplexity), true + case "Media.album": if e.complexity.Media.Album == nil { break @@ -427,6 +542,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Media.Exif(childComplexity), true + case "Media.faces": + if e.complexity.Media.Faces == nil { + break + } + + return e.complexity.Media.Faces(childComplexity), true + case "Media.favorite": if e.complexity.Media.Favorite == nil { break @@ -635,6 +757,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.AuthorizeUser(childComplexity, args["username"].(string), args["password"].(string)), true + case "Mutation.combineFaceGroups": + if e.complexity.Mutation.CombineFaceGroups == nil { + break + } + + args, err := ec.field_Mutation_combineFaceGroups_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.CombineFaceGroups(childComplexity, args["destinationFaceGroupID"].(int), args["sourceFaceGroupID"].(int)), true + case "Mutation.createUser": if e.complexity.Mutation.CreateUser == nil { break @@ -671,6 +805,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.DeleteUser(childComplexity, args["id"].(int)), true + case "Mutation.detachImageFaces": + if e.complexity.Mutation.DetachImageFaces == nil { + break + } + + args, err := ec.field_Mutation_detachImageFaces_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.DetachImageFaces(childComplexity, args["imageFaceIDs"].([]int)), true + case "Mutation.favoriteMedia": if e.complexity.Mutation.FavoriteMedia == nil { break @@ -695,6 +841,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.InitialSetupWizard(childComplexity, args["username"].(string), args["password"].(string), args["rootPath"].(string)), true + case "Mutation.moveImageFaces": + if e.complexity.Mutation.MoveImageFaces == nil { + break + } + + args, err := ec.field_Mutation_moveImageFaces_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.MoveImageFaces(childComplexity, args["imageFaceIDs"].([]int), args["destinationFaceGroupID"].(int)), true + case "Mutation.protectShareToken": if e.complexity.Mutation.ProtectShareToken == nil { break @@ -707,6 +865,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ProtectShareToken(childComplexity, args["token"].(string), args["password"].(*string)), true + case "Mutation.recognizeUnlabeledFaces": + if e.complexity.Mutation.RecognizeUnlabeledFaces == nil { + break + } + + return e.complexity.Mutation.RecognizeUnlabeledFaces(childComplexity), true + case "Mutation.scanAll": if e.complexity.Mutation.ScanAll == nil { break @@ -726,6 +891,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ScanUser(childComplexity, args["userId"].(int)), true + case "Mutation.setFaceGroupLabel": + if e.complexity.Mutation.SetFaceGroupLabel == nil { + break + } + + args, err := ec.field_Mutation_setFaceGroupLabel_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SetFaceGroupLabel(childComplexity, args["faceGroupID"].(int), args["label"].(*string)), true + case "Mutation.setPeriodicScanInterval": if e.complexity.Mutation.SetPeriodicScanInterval == nil { break @@ -921,6 +1098,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.MyAlbums(childComplexity, args["order"].(*models.Ordering), args["paginate"].(*models.Pagination), args["onlyRoot"].(*bool), args["showEmpty"].(*bool), args["onlyWithFavorites"].(*bool)), true + case "Query.myFaceGroups": + if e.complexity.Query.MyFaceGroups == nil { + break + } + + args, err := ec.field_Query_myFaceGroups_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.MyFaceGroups(childComplexity, args["paginate"].(*models.Pagination)), true + case "Query.myMedia": if e.complexity.Query.MyMedia == nil { break @@ -1415,6 +1604,8 @@ type Query { shareTokenValidatePassword(token: String!, password: String): Boolean! search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult! + + myFaceGroups(paginate: Pagination): [FaceGroup!]! } type Mutation { @@ -1469,6 +1660,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 { @@ -1618,6 +1820,8 @@ type Media { shares: [ShareToken!]! downloads: [MediaDownload!]! + + faces: [ImageFace!]! } "EXIF metadata from the camera" @@ -1670,6 +1874,26 @@ 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! +} `, BuiltIn: false}, } var parsedSchema = gqlparser.MustLoadSchema(sources...) @@ -1759,6 +1983,30 @@ func (ec *executionContext) field_Mutation_authorizeUser_args(ctx context.Contex return args, nil } +func (ec *executionContext) field_Mutation_combineFaceGroups_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["destinationFaceGroupID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("destinationFaceGroupID")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["destinationFaceGroupID"] = arg0 + var arg1 int + if tmp, ok := rawArgs["sourceFaceGroupID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("sourceFaceGroupID")) + arg1, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["sourceFaceGroupID"] = arg1 + return args, nil +} + func (ec *executionContext) field_Mutation_createUser_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -1822,6 +2070,21 @@ func (ec *executionContext) field_Mutation_deleteUser_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_detachImageFaces_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 []int + if tmp, ok := rawArgs["imageFaceIDs"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("imageFaceIDs")) + arg0, err = ec.unmarshalNID2ᚕintᚄ(ctx, tmp) + if err != nil { + return nil, err + } + } + args["imageFaceIDs"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_favoriteMedia_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -1879,6 +2142,30 @@ func (ec *executionContext) field_Mutation_initialSetupWizard_args(ctx context.C return args, nil } +func (ec *executionContext) field_Mutation_moveImageFaces_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 []int + if tmp, ok := rawArgs["imageFaceIDs"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("imageFaceIDs")) + arg0, err = ec.unmarshalNID2ᚕintᚄ(ctx, tmp) + if err != nil { + return nil, err + } + } + args["imageFaceIDs"] = arg0 + var arg1 int + if tmp, ok := rawArgs["destinationFaceGroupID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("destinationFaceGroupID")) + arg1, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["destinationFaceGroupID"] = arg1 + return args, nil +} + func (ec *executionContext) field_Mutation_protectShareToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -1918,6 +2205,30 @@ func (ec *executionContext) field_Mutation_scanUser_args(ctx context.Context, ra return args, nil } +func (ec *executionContext) field_Mutation_setFaceGroupLabel_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["faceGroupID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("faceGroupID")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["faceGroupID"] = arg0 + var arg1 *string + if tmp, ok := rawArgs["label"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("label")) + arg1, err = ec.unmarshalOString2ᚖstring(ctx, tmp) + if err != nil { + return nil, err + } + } + args["label"] = arg1 + return args, nil +} + func (ec *executionContext) field_Mutation_setPeriodicScanInterval_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2215,6 +2526,21 @@ func (ec *executionContext) field_Query_myAlbums_args(ctx context.Context, rawAr return args, nil } +func (ec *executionContext) field_Query_myFaceGroups_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 *models.Pagination + if tmp, ok := rawArgs["paginate"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("paginate")) + arg0, err = ec.unmarshalOPagination2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐPagination(ctx, tmp) + if err != nil { + return nil, err + } + } + args["paginate"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_myMedia_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2863,6 +3189,385 @@ func (ec *executionContext) _AuthorizeResult_token(ctx context.Context, field gr return ec.marshalOString2ᚖstring(ctx, field.Selections, res) } +func (ec *executionContext) _FaceGroup_id(ctx context.Context, field graphql.CollectedField, obj *models.FaceGroup) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "FaceGroup", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNID2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _FaceGroup_label(ctx context.Context, field graphql.CollectedField, obj *models.FaceGroup) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "FaceGroup", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Label, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) _FaceGroup_imageFaces(ctx context.Context, field graphql.CollectedField, obj *models.FaceGroup) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "FaceGroup", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ImageFaces, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]models.ImageFace) + fc.Result = res + return ec.marshalNImageFace2ᚕgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) _FaceRectangle_minX(ctx context.Context, field graphql.CollectedField, obj *models.FaceRectangle) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "FaceRectangle", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.MinX, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + +func (ec *executionContext) _FaceRectangle_maxX(ctx context.Context, field graphql.CollectedField, obj *models.FaceRectangle) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "FaceRectangle", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.MaxX, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + +func (ec *executionContext) _FaceRectangle_minY(ctx context.Context, field graphql.CollectedField, obj *models.FaceRectangle) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "FaceRectangle", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.MinY, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + +func (ec *executionContext) _FaceRectangle_maxY(ctx context.Context, field graphql.CollectedField, obj *models.FaceRectangle) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "FaceRectangle", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.MaxY, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + +func (ec *executionContext) _ImageFace_id(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "ImageFace", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNID2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "ImageFace", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Media, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(models.Media) + fc.Result = res + return ec.marshalNMedia2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res) +} + +func (ec *executionContext) _ImageFace_rectangle(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "ImageFace", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Rectangle, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(models.FaceRectangle) + fc.Result = res + return ec.marshalOFaceRectangle2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceRectangle(ctx, field.Selections, res) +} + +func (ec *executionContext) _ImageFace_faceGroup(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "ImageFace", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.ImageFace().FaceGroup(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.FaceGroup) + fc.Result = res + return ec.marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx, field.Selections, res) +} + func (ec *executionContext) _Media_id(ctx context.Context, field graphql.CollectedField, obj *models.Media) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3303,6 +4008,41 @@ func (ec *executionContext) _Media_downloads(ctx context.Context, field graphql. return ec.marshalNMediaDownload2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMediaDownloadᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _Media_faces(ctx context.Context, field graphql.CollectedField, obj *models.Media) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Media", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Media().Faces(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*models.ImageFace) + fc.Result = res + return ec.marshalNImageFace2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx, field.Selections, res) +} + func (ec *executionContext) _MediaDownload_title(ctx context.Context, field graphql.CollectedField, obj *models.MediaDownload) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -4655,6 +5395,209 @@ func (ec *executionContext) _Mutation_setScannerConcurrentWorkers(ctx context.Co return ec.marshalNInt2int(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_setFaceGroupLabel(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_setFaceGroupLabel_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().SetFaceGroupLabel(rctx, args["faceGroupID"].(int), args["label"].(*string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.FaceGroup) + fc.Result = res + return ec.marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_combineFaceGroups(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_combineFaceGroups_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().CombineFaceGroups(rctx, args["destinationFaceGroupID"].(int), args["sourceFaceGroupID"].(int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.FaceGroup) + fc.Result = res + return ec.marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_moveImageFaces(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_moveImageFaces_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().MoveImageFaces(rctx, args["imageFaceIDs"].([]int), args["destinationFaceGroupID"].(int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.FaceGroup) + fc.Result = res + return ec.marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_recognizeUnlabeledFaces(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().RecognizeUnlabeledFaces(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*models.ImageFace) + fc.Result = res + return ec.marshalNImageFace2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_detachImageFaces(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_detachImageFaces_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().DetachImageFaces(rctx, args["imageFaceIDs"].([]int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.FaceGroup) + fc.Result = res + return ec.marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx, field.Selections, res) +} + func (ec *executionContext) _Notification_key(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5506,6 +6449,48 @@ func (ec *executionContext) _Query_search(ctx context.Context, field graphql.Col return ec.marshalNSearchResult2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐSearchResult(ctx, field.Selections, res) } +func (ec *executionContext) _Query_myFaceGroups(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_myFaceGroups_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().MyFaceGroups(rctx, args["paginate"].(*models.Pagination)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*models.FaceGroup) + fc.Result = res + return ec.marshalNFaceGroup2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroupᚄ(ctx, field.Selections, res) +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -8234,6 +9219,130 @@ func (ec *executionContext) _AuthorizeResult(ctx context.Context, sel ast.Select return out } +var faceGroupImplementors = []string{"FaceGroup"} + +func (ec *executionContext) _FaceGroup(ctx context.Context, sel ast.SelectionSet, obj *models.FaceGroup) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, faceGroupImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("FaceGroup") + case "id": + out.Values[i] = ec._FaceGroup_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "label": + out.Values[i] = ec._FaceGroup_label(ctx, field, obj) + case "imageFaces": + out.Values[i] = ec._FaceGroup_imageFaces(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + +var faceRectangleImplementors = []string{"FaceRectangle"} + +func (ec *executionContext) _FaceRectangle(ctx context.Context, sel ast.SelectionSet, obj *models.FaceRectangle) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, faceRectangleImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("FaceRectangle") + case "minX": + out.Values[i] = ec._FaceRectangle_minX(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "maxX": + out.Values[i] = ec._FaceRectangle_maxX(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "minY": + out.Values[i] = ec._FaceRectangle_minY(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "maxY": + out.Values[i] = ec._FaceRectangle_maxY(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + +var imageFaceImplementors = []string{"ImageFace"} + +func (ec *executionContext) _ImageFace(ctx context.Context, sel ast.SelectionSet, obj *models.ImageFace) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, imageFaceImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ImageFace") + case "id": + out.Values[i] = ec._ImageFace_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + case "media": + out.Values[i] = ec._ImageFace_media(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + case "rectangle": + out.Values[i] = ec._ImageFace_rectangle(ctx, field, obj) + case "faceGroup": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._ImageFace_faceGroup(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var mediaImplementors = []string{"Media"} func (ec *executionContext) _Media(ctx context.Context, sel ast.SelectionSet, obj *models.Media) graphql.Marshaler { @@ -8358,6 +9467,20 @@ func (ec *executionContext) _Media(ctx context.Context, sel ast.SelectionSet, ob } return res }) + case "faces": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Media_faces(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8557,6 +9680,31 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "setFaceGroupLabel": + out.Values[i] = ec._Mutation_setFaceGroupLabel(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "combineFaceGroups": + out.Values[i] = ec._Mutation_combineFaceGroups(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "moveImageFaces": + out.Values[i] = ec._Mutation_moveImageFaces(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "recognizeUnlabeledFaces": + out.Values[i] = ec._Mutation_recognizeUnlabeledFaces(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "detachImageFaces": + out.Values[i] = ec._Mutation_detachImageFaces(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8832,6 +9980,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "myFaceGroups": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_myFaceGroups(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "__type": out.Values[i] = ec._Query___type(ctx, field) case "__schema": @@ -9544,6 +10706,57 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) marshalNFaceGroup2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx context.Context, sel ast.SelectionSet, v models.FaceGroup) graphql.Marshaler { + return ec._FaceGroup(ctx, sel, &v) +} + +func (ec *executionContext) marshalNFaceGroup2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroupᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.FaceGroup) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + +func (ec *executionContext) marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx context.Context, sel ast.SelectionSet, v *models.FaceGroup) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._FaceGroup(ctx, sel, v) +} + func (ec *executionContext) unmarshalNFloat2float64(ctx context.Context, v interface{}) (float64, error) { res, err := graphql.UnmarshalFloat(v) return res, graphql.ErrorOnPath(ctx, err) @@ -9604,6 +10817,94 @@ func (ec *executionContext) marshalNID2ᚕintᚄ(ctx context.Context, sel ast.Se return ret } +func (ec *executionContext) marshalNImageFace2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFace(ctx context.Context, sel ast.SelectionSet, v models.ImageFace) graphql.Marshaler { + return ec._ImageFace(ctx, sel, &v) +} + +func (ec *executionContext) marshalNImageFace2ᚕgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx context.Context, sel ast.SelectionSet, v []models.ImageFace) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNImageFace2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFace(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + +func (ec *executionContext) marshalNImageFace2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.ImageFace) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNImageFace2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFace(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + +func (ec *executionContext) marshalNImageFace2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFace(ctx context.Context, sel ast.SelectionSet, v *models.ImageFace) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._ImageFace(ctx, sel, v) +} + func (ec *executionContext) unmarshalNInt2int(ctx context.Context, v interface{}) (int, error) { res, err := graphql.UnmarshalInt(v) return res, graphql.ErrorOnPath(ctx, err) @@ -10264,6 +11565,10 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast return graphql.MarshalBoolean(*v) } +func (ec *executionContext) marshalOFaceRectangle2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceRectangle(ctx context.Context, sel ast.SelectionSet, v models.FaceRectangle) graphql.Marshaler { + return ec._FaceRectangle(ctx, sel, &v) +} + func (ec *executionContext) unmarshalOFloat2ᚖfloat64(ctx context.Context, v interface{}) (*float64, error) { if v == nil { return nil, nil diff --git a/api/graphql/models/face_detection.go b/api/graphql/models/face_detection.go new file mode 100644 index 0000000..f3918db --- /dev/null +++ b/api/graphql/models/face_detection.go @@ -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 +} diff --git a/api/graphql/models/media.go b/api/graphql/models/media.go index 5085f23..cd8e093 100644 --- a/api/graphql/models/media.go +++ b/api/graphql/models/media.go @@ -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" ) @@ -24,7 +27,8 @@ type Media struct { VideoMetadataID *int `gorm:"index"` VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:CASCADE;"` SideCarPath *string - SideCarHash *string `gorm:"unique"` + 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, "/", "") diff --git a/api/graphql/resolvers/faces.go b/api/graphql/resolvers/faces.go new file mode 100644 index 0000000..2ed1fd7 --- /dev/null +++ b/api/graphql/resolvers/faces.go @@ -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 +} diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index bb0d270..b32117f 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -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 +} diff --git a/api/graphql/resolvers/user.go b/api/graphql/resolvers/user.go index 95406b8..2a1fff0 100644 --- a/api/graphql/resolvers/user.go +++ b/api/graphql/resolvers/user.go @@ -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 diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index fe67538..15de4c0 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -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! +} diff --git a/api/routes/photos.go b/api/routes/photos.go index 9da3826..74bea71 100644 --- a/api/routes/photos.go +++ b/api/routes/photos.go @@ -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 diff --git a/api/routes/videos.go b/api/routes/videos.go index 92c866f..67fea3a 100644 --- a/api/routes/videos.go +++ b/api/routes/videos.go @@ -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) diff --git a/api/scanner/cleanup_media.go b/api/scanner/cleanup_media.go index aca9c4b..6fa8122 100644 --- a/api/scanner/cleanup_media.go +++ b/api/scanner/cleanup_media.go @@ -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)) diff --git a/api/scanner/encode_photo.go b/api/scanner/encode_photo.go index 377529e..faf9e0b 100644 --- a/api/scanner/encode_photo.go +++ b/api/scanner/encode_photo.go @@ -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) diff --git a/api/scanner/face_detection/face_detector.go b/api/scanner/face_detection/face_detector.go new file mode 100644 index 0000000..f0ef2cf --- /dev/null +++ b/api/scanner/face_detection/face_detector.go @@ -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 +} diff --git a/api/scanner/image_helpers/photo_dimensions.go b/api/scanner/image_helpers/photo_dimensions.go new file mode 100644 index 0000000..84dc146 --- /dev/null +++ b/api/scanner/image_helpers/photo_dimensions.go @@ -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, + } +} diff --git a/api/scanner/media_type.go b/api/scanner/media_type.go index bc77a0f..46f9e2e 100644 --- a/api/scanner/media_type.go +++ b/api/scanner/media_type.go @@ -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, diff --git a/api/scanner/process_photo.go b/api/scanner/process_photo.go index 5cecead..389d25c 100644 --- a/api/scanner/process_photo.go +++ b/api/scanner/process_photo.go @@ -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 } diff --git a/api/scanner/process_video.go b/api/scanner/process_video.go index e2e579e..342ad78 100644 --- a/api/scanner/process_video.go +++ b/api/scanner/process_video.go @@ -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") } diff --git a/api/scanner/scanner_album.go b/api/scanner/scanner_album.go index c1dae39..cfcf0e5 100644 --- a/api/scanner/scanner_album.go +++ b/api/scanner/scanner_album.go @@ -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 }) diff --git a/api/scanner/scanner_user.go b/api/scanner/scanner_user.go index 06fa1b1..6470a5e 100644 --- a/api/scanner/scanner_user.go +++ b/api/scanner/scanner_user.go @@ -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 -} diff --git a/api/server.go b/api/server.go index 11699b7..67f0cd8 100644 --- a/api/server.go +++ b/api/server.go @@ -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)), ), ) diff --git a/api/utils/utils.go b/api/utils/utils.go index 672b0cb..e2573b1 100644 --- a/api/utils/utils.go +++ b/api/utils/utils.go @@ -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 +} diff --git a/docker/go_wrapper.sh b/docker/go_wrapper.sh new file mode 100644 index 0000000..4526ecc --- /dev/null +++ b/docker/go_wrapper.sh @@ -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 "$@" diff --git a/ui/src/Layout.js b/ui/src/Layout.js index c5c6fd8..9b9ad38 100644 --- a/ui/src/Layout.js +++ b/ui/src/Layout.js @@ -104,19 +104,23 @@ export const SideMenu = () => { return ( - + Photos - + Albums {mapboxEnabled ? ( - + Places ) : null} + + + People + {isAdmin ? ( diff --git a/ui/src/Pages/PeoplePage/FaceCircleImage.js b/ui/src/Pages/PeoplePage/FaceCircleImage.js new file mode 100644 index 0000000..d77dc14 --- /dev/null +++ b/ui/src/Pages/PeoplePage/FaceCircleImage.js @@ -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 ( + + + + ) +} + +FaceCircleImage.propTypes = { + imageFace: PropTypes.object, + selectable: PropTypes.bool, + size: PropTypes.string, +} + +export default FaceCircleImage diff --git a/ui/src/Pages/PeoplePage/PeoplePage.js b/ui/src/Pages/PeoplePage/PeoplePage.js new file mode 100644 index 0000000..f4476d8 --- /dev/null +++ b/ui/src/Pages/PeoplePage/PeoplePage.js @@ -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 = ( + setEditLabel(true)} + > + {group.imageFaces.length} + {group.label ?? 'Unlabeled'} + + + ) + } else { + label = ( + + setInputValue(e.target.value)} + onBlur={() => { + resetLabel() + }} + /> + + ) + } + + 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 ( +
+ + + + +
+ ) +} + +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 ( + + x.id == faceGroup)} + /> + + ) + } + + let faces = null + if (data) { + faces = data.myFaceGroups.map(faceGroup => ( + + )) + } + + return ( + + {faces} + + + ) +} + +PeoplePage.propTypes = { + match: PropTypes.object.isRequired, +} + +export default PeoplePage diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.js new file mode 100644 index 0000000..7a53d87 --- /dev/null +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.js @@ -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 ( + setOpen(false)} + onOpen={() => setOpen(true)} + open={open} + > + Detach Image Faces + + +

+ Detach selected images of this face group and move them to a new + face group +

+ +
+
+ + + + + {positiveButton} + +
+ ) +} + +MoveImageFacesModal.propTypes = { + open: PropTypes.bool.isRequired, + setOpen: PropTypes.func.isRequired, + faceGroup: PropTypes.object, +} + +export default MoveImageFacesModal diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectFaceGroupTable.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectFaceGroupTable.js new file mode 100644 index 0000000..e8594dd --- /dev/null +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectFaceGroupTable.js @@ -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 ( + + + + + + {faceGroup.label} + + + ) +} + +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 => ( + setSelectedFaceGroup(face)} + /> + )) + + const pageRows = rows.filter( + (_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE + ) + + return ( + + + + {title} + + + + setSearchValue(e.target.value)} + icon="search" + placeholder="Search faces..." + fluid + /> + + + + {pageRows} + + + + { + setPage(Math.ceil(activePage) - 1) + }} + /> + + + +
+ ) +} + +SelectFaceGroupTable.propTypes = { + faceGroups: PropTypes.array, + selectedFaceGroup: PropTypes.object, + setSelectedFaceGroup: PropTypes.func.isRequired, + title: PropTypes.string, +} + +export default SelectFaceGroupTable diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectImageFacesTable.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectImageFacesTable.js new file mode 100644 index 0000000..c7d7abf --- /dev/null +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectImageFacesTable.js @@ -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 ( + + + + + + + + + + {imageFace.media.title} + + + + ) +} + +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 => ( + + 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 ( + + + + {title} + + + + setSearchValue(e.target.value)} + icon="search" + placeholder="Search images..." + fluid + /> + + + + {pageRows} + + + + { + setPage(Math.ceil(activePage) - 1) + }} + /> + + + +
+ ) +} + +SelectImageFacesTable.propTypes = { + imageFaces: PropTypes.array, + selectedImageFaces: PropTypes.array, + setSelectedImageFaces: PropTypes.func.isRequired, + title: PropTypes.string, +} + +export default SelectImageFacesTable diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.js new file mode 100644 index 0000000..c2846e2 --- /dev/null +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.js @@ -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 = ( +
+ +
+ ) + } + + return ( +
+ + {mediaGallery} +
+ ) +} + +SingleFaceGroup.propTypes = { + faceGroup: PropTypes.object, +} + +export default SingleFaceGroup diff --git a/ui/src/Pages/PlacesPage/PlacesPage.js b/ui/src/Pages/PlacesPage/PlacesPage.js index ce23516..9db3ee8 100644 --- a/ui/src/Pages/PlacesPage/PlacesPage.js +++ b/ui/src/Pages/PlacesPage/PlacesPage.js @@ -112,7 +112,7 @@ const MapPage = () => { } return ( - + diff --git a/ui/src/Pages/SettingsPage/SettingsPage.js b/ui/src/Pages/SettingsPage/SettingsPage.js index 2c815f2..04e4334 100644 --- a/ui/src/Pages/SettingsPage/SettingsPage.js +++ b/ui/src/Pages/SettingsPage/SettingsPage.js @@ -25,7 +25,7 @@ export const InputLabelDescription = styled.p` const SettingsPage = () => { return ( - + diff --git a/ui/src/components/facesOverlay/FacesOverlay.js b/ui/src/components/facesOverlay/FacesOverlay.js new file mode 100644 index 0000000..74a6964 --- /dev/null +++ b/ui/src/components/facesOverlay/FacesOverlay.js @@ -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 ( + + ) +} + +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 => ( + + )) + + let wrapperWidth = 1 + if (media.thumbnail.width * 0.75 < media.thumbnail.height) { + wrapperWidth = (media.thumbnail.width * 0.75) / media.thumbnail.height + } + + return ( + + {faceBoxes} + + ) +} + +SidebarFacesOverlay.propTypes = { + media: PropTypes.object.isRequired, +} diff --git a/ui/src/components/routes/Routes.js b/ui/src/components/routes/Routes.js index e6a7cf0..bd72569 100644 --- a/ui/src/components/routes/Routes.js +++ b/ui/src/components/routes/Routes.js @@ -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 = () => { - - + + + } />
Page not found
} /> diff --git a/ui/src/components/sidebar/MediaSidebar.js b/ui/src/components/sidebar/MediaSidebar.js index 4d4a738..906d29b 100644 --- a/ui/src/components/sidebar/MediaSidebar.js +++ b/ui/src/components/sidebar/MediaSidebar.js @@ -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 && ( + )} {media && media.title}