Merge pull request #197 from photoview/face-detection
Implement Face Detection
This commit is contained in:
commit
a156af40e2
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
25
Dockerfile
25
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
|
||||
|
|
15
README.md
15
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
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -32,6 +32,8 @@ models:
|
|||
fields:
|
||||
exif:
|
||||
resolver: true
|
||||
faces:
|
||||
resolver: true
|
||||
MediaURL:
|
||||
model: github.com/photoview/photoview/api/graphql/models.MediaURL
|
||||
MediaEXIF:
|
||||
|
@ -42,5 +44,14 @@ models:
|
|||
model: github.com/photoview/photoview/api/graphql/models.Album
|
||||
ShareToken:
|
||||
model: github.com/photoview/photoview/api/graphql/models.ShareToken
|
||||
FaceGroup:
|
||||
model: github.com/photoview/photoview/api/graphql/models.FaceGroup
|
||||
ImageFace:
|
||||
model: github.com/photoview/photoview/api/graphql/models.ImageFace
|
||||
fields:
|
||||
faceGroup:
|
||||
resolver: true
|
||||
FaceRectangle:
|
||||
model: github.com/photoview/photoview/api/graphql/models.FaceRectangle
|
||||
SiteInfo:
|
||||
model: github.com/photoview/photoview/api/graphql/models.SiteInfo
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
}
|
|
@ -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, "/", "")
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 "$@"
|
|
@ -104,19 +104,23 @@ export const SideMenu = () => {
|
|||
return (
|
||||
<SideMenuContainer>
|
||||
<SideButton to="/photos" exact>
|
||||
<Icon name="image outline" />
|
||||
<Icon name="image" />
|
||||
<SideButtonLabel>Photos</SideButtonLabel>
|
||||
</SideButton>
|
||||
<SideButton to="/albums" exact>
|
||||
<Icon name="images outline" />
|
||||
<Icon name="images" />
|
||||
<SideButtonLabel>Albums</SideButtonLabel>
|
||||
</SideButton>
|
||||
{mapboxEnabled ? (
|
||||
<SideButton to="/places" exact>
|
||||
<Icon name="map outline" />
|
||||
<Icon name="map" />
|
||||
<SideButtonLabel>Places</SideButtonLabel>
|
||||
</SideButton>
|
||||
) : null}
|
||||
<SideButton to="/people" exact>
|
||||
<Icon name="user" />
|
||||
<SideButtonLabel>People</SideButtonLabel>
|
||||
</SideButton>
|
||||
{isAdmin ? (
|
||||
<SideButton to="/settings" exact>
|
||||
<Icon name="settings" />
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import styled from 'styled-components'
|
||||
import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
|
||||
|
||||
const FaceImage = styled(ProtectedImage)`
|
||||
position: absolute;
|
||||
transform-origin: ${({ $origin }) =>
|
||||
`${$origin.x * 100}% ${$origin.y * 100}%`};
|
||||
object-fit: cover;
|
||||
|
||||
transition: transform 250ms ease-out;
|
||||
`
|
||||
|
||||
const FaceImagePortrait = styled(FaceImage)`
|
||||
width: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%)
|
||||
${({ $origin, $scale }) =>
|
||||
`translate(${(0.5 - $origin.x) * 100}%, ${
|
||||
(0.5 - $origin.y) * 100
|
||||
}%) scale(${Math.max($scale * 0.8, 1)})`};
|
||||
|
||||
${({ $selectable, $origin, $scale }) =>
|
||||
$selectable
|
||||
? `
|
||||
&:hover {
|
||||
transform: translateY(-50%) translate(${(0.5 - $origin.x) * 100}%, ${
|
||||
(0.5 - $origin.y) * 100
|
||||
}%) scale(${Math.max($scale * 0.85, 1)})
|
||||
`
|
||||
: ''}
|
||||
`
|
||||
|
||||
const FaceImageLandscape = styled(FaceImage)`
|
||||
height: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%)
|
||||
${({ $origin, $scale }) =>
|
||||
`translate(${(0.5 - $origin.x) * 100}%, ${
|
||||
(0.5 - $origin.y) * 100
|
||||
}%) scale(${Math.max($scale * 0.8, 1)})`};
|
||||
|
||||
${({ $selectable, $origin, $scale }) =>
|
||||
$selectable
|
||||
? `
|
||||
&:hover {
|
||||
transform: translateX(-50%) translate(${(0.5 - $origin.x) * 100}%, ${
|
||||
(0.5 - $origin.y) * 100
|
||||
}%) scale(${Math.max($scale * 0.85, 1)})
|
||||
`
|
||||
: ''}
|
||||
`
|
||||
|
||||
const CircleImageWrapper = styled.div`
|
||||
background-color: #eee;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
width: ${({ size }) => size};
|
||||
height: ${({ size }) => size};
|
||||
object-fit: fill;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const FaceCircleImage = ({ imageFace, selectable, size = '150px' }) => {
|
||||
if (!imageFace) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = imageFace.rectangle
|
||||
|
||||
let scale = Math.min(1 / (rect.maxX - rect.minX), 1 / (rect.maxY - rect.minY))
|
||||
|
||||
let origin = {
|
||||
x: (rect.minX + rect.maxX) / 2,
|
||||
y: (rect.minY + rect.maxY) / 2,
|
||||
}
|
||||
|
||||
const SpecificFaceImage =
|
||||
imageFace.media.thumbnail.width > imageFace.media.thumbnail.height
|
||||
? FaceImageLandscape
|
||||
: FaceImagePortrait
|
||||
return (
|
||||
<CircleImageWrapper size={size}>
|
||||
<SpecificFaceImage
|
||||
$selectable={selectable}
|
||||
$scale={scale}
|
||||
$origin={origin}
|
||||
src={imageFace.media.thumbnail.url}
|
||||
/>
|
||||
</CircleImageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
FaceCircleImage.propTypes = {
|
||||
imageFace: PropTypes.object,
|
||||
selectable: PropTypes.bool,
|
||||
size: PropTypes.string,
|
||||
}
|
||||
|
||||
export default FaceCircleImage
|
|
@ -0,0 +1,253 @@
|
|||
import React, { createRef, useEffect, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import Layout from '../../Layout'
|
||||
import styled from 'styled-components'
|
||||
import { Link } from 'react-router-dom'
|
||||
import SingleFaceGroup from './SingleFaceGroup/SingleFaceGroup'
|
||||
import { Button, Icon, Input } from 'semantic-ui-react'
|
||||
import FaceCircleImage from './FaceCircleImage'
|
||||
|
||||
export const MY_FACES_QUERY = gql`
|
||||
query myFaces {
|
||||
myFaceGroups {
|
||||
id
|
||||
label
|
||||
imageFaces {
|
||||
id
|
||||
rectangle {
|
||||
minX
|
||||
maxX
|
||||
minY
|
||||
maxY
|
||||
}
|
||||
media {
|
||||
id
|
||||
type
|
||||
title
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
highRes {
|
||||
url
|
||||
}
|
||||
favorite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_GROUP_LABEL_MUTATION = gql`
|
||||
mutation($groupID: ID!, $label: String) {
|
||||
setFaceGroupLabel(faceGroupID: $groupID, label: $label) {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RECOGNIZE_UNLABELED_FACES_MUTATION = gql`
|
||||
mutation recognizeUnlabeledFaces {
|
||||
recognizeUnlabeledFaces {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FaceDetailsButton = styled.button`
|
||||
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
|
||||
margin: 12px auto 24px;
|
||||
text-align: center;
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: #2683ca;
|
||||
}
|
||||
`
|
||||
|
||||
const FaceLabel = styled.span``
|
||||
|
||||
const FaceDetails = ({ group }) => {
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(group.label ?? '')
|
||||
const inputRef = createRef()
|
||||
|
||||
const [setGroupLabel, { loading }] = useMutation(SET_GROUP_LABEL_MUTATION, {
|
||||
variables: {
|
||||
groupID: group.id,
|
||||
},
|
||||
})
|
||||
|
||||
const resetLabel = () => {
|
||||
setInputValue(group.label ?? '')
|
||||
setEditLabel(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
resetLabel()
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
const onKeyUp = e => {
|
||||
if (e.key == 'Escape') {
|
||||
resetLabel()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key == 'Enter') {
|
||||
setGroupLabel({
|
||||
variables: {
|
||||
label: e.target.value == '' ? null : e.target.value,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let label
|
||||
if (!editLabel) {
|
||||
label = (
|
||||
<FaceDetailsButton
|
||||
labeled={!!group.label}
|
||||
onClick={() => setEditLabel(true)}
|
||||
>
|
||||
<FaceImagesCount>{group.imageFaces.length}</FaceImagesCount>
|
||||
<FaceLabel>{group.label ?? 'Unlabeled'}</FaceLabel>
|
||||
<EditIcon name="pencil" />
|
||||
</FaceDetailsButton>
|
||||
)
|
||||
} else {
|
||||
label = (
|
||||
<FaceDetailsButton labeled={!!group.label}>
|
||||
<Input
|
||||
loading={loading}
|
||||
ref={inputRef}
|
||||
size="mini"
|
||||
placeholder="Label"
|
||||
icon="arrow right"
|
||||
value={inputValue}
|
||||
onKeyUp={onKeyUp}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
resetLabel()
|
||||
}}
|
||||
/>
|
||||
</FaceDetailsButton>
|
||||
)
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
FaceDetails.propTypes = {
|
||||
group: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
const FaceImagesCount = styled.span`
|
||||
background-color: #eee;
|
||||
color: #222;
|
||||
font-size: 0.9em;
|
||||
padding: 0 4px;
|
||||
margin-right: 6px;
|
||||
border-radius: 4px;
|
||||
`
|
||||
|
||||
const EditIcon = styled(Icon)`
|
||||
margin-left: 6px !important;
|
||||
opacity: 0 !important;
|
||||
|
||||
transition: opacity 100ms;
|
||||
|
||||
${FaceDetailsButton}:hover &, ${FaceDetailsButton}:focus-visible & {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`
|
||||
|
||||
const FaceGroup = ({ group }) => {
|
||||
const previewFace = group.imageFaces[0]
|
||||
|
||||
return (
|
||||
<div style={{ margin: '12px' }}>
|
||||
<Link to={`/people/${group.id}`}>
|
||||
<FaceCircleImage imageFace={previewFace} selectable />
|
||||
</Link>
|
||||
<FaceDetails group={group} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FaceGroup.propTypes = {
|
||||
group: PropTypes.any,
|
||||
}
|
||||
|
||||
const FaceGroupsWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`
|
||||
|
||||
const PeoplePage = ({ match }) => {
|
||||
const { data, error } = useQuery(MY_FACES_QUERY)
|
||||
|
||||
const [
|
||||
recognizeUnlabeled,
|
||||
{ loading: recognizeUnlabeledLoading },
|
||||
] = useMutation(RECOGNIZE_UNLABELED_FACES_MUTATION)
|
||||
|
||||
if (error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
const faceGroup = match.params.person
|
||||
if (faceGroup) {
|
||||
return (
|
||||
<Layout>
|
||||
<SingleFaceGroup
|
||||
faceGroup={data?.myFaceGroups?.find(x => x.id == faceGroup)}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
let faces = null
|
||||
if (data) {
|
||||
faces = data.myFaceGroups.map(faceGroup => (
|
||||
<FaceGroup key={faceGroup.id} group={faceGroup} />
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={'People'}>
|
||||
<FaceGroupsWrapper>{faces}</FaceGroupsWrapper>
|
||||
<Button
|
||||
loading={recognizeUnlabeledLoading}
|
||||
disabled={recognizeUnlabeledLoading}
|
||||
onClick={() => {
|
||||
recognizeUnlabeled()
|
||||
}}
|
||||
>
|
||||
<Icon name="sync" />
|
||||
Recognize unlabeled faces
|
||||
</Button>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
PeoplePage.propTypes = {
|
||||
match: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export default PeoplePage
|
|
@ -0,0 +1,96 @@
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Button, Modal } from 'semantic-ui-react'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import SelectImageFacesTable from './SelectImageFacesTable'
|
||||
|
||||
const DETACH_IMAGE_FACES_MUTATION = gql`
|
||||
mutation detachImageFaces($faceIDs: [ID!]!) {
|
||||
detachImageFaces(imageFaceIDs: $faceIDs) {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DetachImageFacesModal = ({ open, setOpen, faceGroup }) => {
|
||||
const [selectedImageFaces, setSelectedImageFaces] = useState([])
|
||||
let history = useHistory()
|
||||
|
||||
const [detachImageFacesMutation] = useMutation(DETACH_IMAGE_FACES_MUTATION, {
|
||||
variables: {},
|
||||
refetchQueries: [
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedImageFaces([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (open == false) return null
|
||||
|
||||
const detachImageFaces = () => {
|
||||
const faceIDs = selectedImageFaces.map(face => face.id)
|
||||
|
||||
detachImageFacesMutation({
|
||||
variables: {
|
||||
faceIDs,
|
||||
},
|
||||
}).then(({ data }) => {
|
||||
setOpen(false)
|
||||
history.push(`/people/${data.detachImageFaces.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
const imageFaces = faceGroup?.imageFaces ?? []
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => setOpen(true)}
|
||||
open={open}
|
||||
>
|
||||
<Modal.Header>Detach Image Faces</Modal.Header>
|
||||
<Modal.Content scrolling>
|
||||
<Modal.Description>
|
||||
<p>
|
||||
Detach selected images of this face group and move them to a new
|
||||
face group
|
||||
</p>
|
||||
<SelectImageFacesTable
|
||||
imageFaces={imageFaces}
|
||||
selectedImageFaces={selectedImageFaces}
|
||||
setSelectedImageFaces={setSelectedImageFaces}
|
||||
title="Select images to detach"
|
||||
/>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={selectedImageFaces.length == 0}
|
||||
content="Detach image faces"
|
||||
labelPosition="right"
|
||||
icon="checkmark"
|
||||
onClick={() => detachImageFaces()}
|
||||
positive
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
DetachImageFacesModal.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
setOpen: PropTypes.func.isRequired,
|
||||
faceGroup: PropTypes.object,
|
||||
}
|
||||
|
||||
export default DetachImageFacesModal
|
|
@ -0,0 +1,164 @@
|
|||
import { useMutation } from '@apollo/client'
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useState, useEffect, createRef } from 'react'
|
||||
import { Dropdown, Input } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
|
||||
import DetachImageFacesModal from './DetachImageFacesModal'
|
||||
import MergeFaceGroupsModal from './MergeFaceGroupsModal'
|
||||
import MoveImageFacesModal from './MoveImageFacesModal'
|
||||
|
||||
const TitleWrapper = styled.div`
|
||||
min-height: 3.5em;
|
||||
`
|
||||
|
||||
const TitleLabel = styled.h1`
|
||||
display: inline-block;
|
||||
color: ${({ labeled }) => (labeled ? 'black' : '#888')};
|
||||
margin-right: 12px;
|
||||
`
|
||||
|
||||
const TitleDropdown = styled(Dropdown)`
|
||||
vertical-align: middle;
|
||||
margin-top: -10px;
|
||||
color: #888;
|
||||
|
||||
&:hover {
|
||||
color: #1e70bf;
|
||||
}
|
||||
`
|
||||
|
||||
const FaceGroupTitle = ({ faceGroup }) => {
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
|
||||
const inputRef = createRef()
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false)
|
||||
const [moveModalOpen, setMoveModalOpen] = useState(false)
|
||||
const [detachModalOpen, setDetachModalOpen] = useState(false)
|
||||
|
||||
const [setGroupLabel, { loading: setLabelLoading }] = useMutation(
|
||||
SET_GROUP_LABEL_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
groupID: faceGroup?.id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const resetLabel = () => {
|
||||
setInputValue(faceGroup?.label ?? '')
|
||||
setEditLabel(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setLabelLoading) {
|
||||
resetLabel()
|
||||
}
|
||||
}, [setLabelLoading])
|
||||
|
||||
const onKeyUp = e => {
|
||||
if (e.key == 'Escape') {
|
||||
resetLabel()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key == 'Enter') {
|
||||
setGroupLabel({
|
||||
variables: {
|
||||
label: e.target.value == '' ? null : e.target.value,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let title
|
||||
if (!editLabel) {
|
||||
title = (
|
||||
<TitleWrapper>
|
||||
<TitleLabel labeled={!!faceGroup?.label}>
|
||||
{faceGroup?.label ?? 'Unlabeled person'}
|
||||
</TitleLabel>
|
||||
<TitleDropdown
|
||||
icon={{
|
||||
name: 'settings',
|
||||
size: 'large',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
icon="pencil"
|
||||
text={faceGroup?.label ? 'Change Label' : 'Add Label'}
|
||||
onClick={() => setEditLabel(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="object group"
|
||||
text="Merge Face"
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="object ungroup"
|
||||
text="Detach Faces"
|
||||
onClick={() => setDetachModalOpen(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="clone"
|
||||
text="Move Faces"
|
||||
onClick={() => setMoveModalOpen(true)}
|
||||
/>
|
||||
</Dropdown.Menu>
|
||||
</TitleDropdown>
|
||||
</TitleWrapper>
|
||||
)
|
||||
} else {
|
||||
title = (
|
||||
<TitleWrapper>
|
||||
<Input
|
||||
loading={setLabelLoading}
|
||||
ref={inputRef}
|
||||
placeholder="Label"
|
||||
icon="arrow right"
|
||||
value={inputValue}
|
||||
onKeyUp={onKeyUp}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
resetLabel()
|
||||
}}
|
||||
/>
|
||||
</TitleWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<MergeFaceGroupsModal
|
||||
open={mergeModalOpen}
|
||||
setOpen={setMergeModalOpen}
|
||||
sourceFaceGroup={faceGroup}
|
||||
/>
|
||||
<MoveImageFacesModal
|
||||
open={moveModalOpen}
|
||||
setOpen={setMoveModalOpen}
|
||||
faceGroup={faceGroup}
|
||||
/>
|
||||
<DetachImageFacesModal
|
||||
open={detachModalOpen}
|
||||
setOpen={setDetachModalOpen}
|
||||
faceGroup={faceGroup}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
FaceGroupTitle.propTypes = {
|
||||
faceGroup: PropTypes.object,
|
||||
}
|
||||
|
||||
export default FaceGroupTitle
|
|
@ -0,0 +1,94 @@
|
|||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Button, Modal } from 'semantic-ui-react'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import SelectFaceGroupTable from './SelectFaceGroupTable'
|
||||
|
||||
const COMBINE_FACES_MUTATION = gql`
|
||||
mutation($destID: ID!, $srcID: ID!) {
|
||||
combineFaceGroups(
|
||||
destinationFaceGroupID: $destID
|
||||
sourceFaceGroupID: $srcID
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MergeFaceGroupsModal = ({ open, setOpen, sourceFaceGroup }) => {
|
||||
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
|
||||
|
||||
let history = useHistory()
|
||||
const { data } = useQuery(MY_FACES_QUERY)
|
||||
const [combineFacesMutation] = useMutation(COMBINE_FACES_MUTATION, {
|
||||
variables: {
|
||||
srcID: sourceFaceGroup?.id,
|
||||
},
|
||||
refetchQueries: [
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (open == false) return null
|
||||
|
||||
const filteredFaceGroups =
|
||||
data?.myFaceGroups.filter(x => x.id != sourceFaceGroup?.id) ?? []
|
||||
|
||||
const mergeFaceGroups = () => {
|
||||
combineFacesMutation({
|
||||
variables: {
|
||||
destID: selectedFaceGroup.id,
|
||||
},
|
||||
}).then(() => {
|
||||
setOpen(false)
|
||||
history.push(`/people/${selectedFaceGroup.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => setOpen(true)}
|
||||
open={open}
|
||||
>
|
||||
<Modal.Header>Merge Face Groups</Modal.Header>
|
||||
<Modal.Content scrolling>
|
||||
<Modal.Description>
|
||||
<p>
|
||||
All images within this face group will be merged into the selected
|
||||
face group.
|
||||
</p>
|
||||
<SelectFaceGroupTable
|
||||
title="Select the destination face"
|
||||
faceGroups={filteredFaceGroups}
|
||||
selectedFaceGroup={selectedFaceGroup}
|
||||
setSelectedFaceGroup={setSelectedFaceGroup}
|
||||
/>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={selectedFaceGroup == null}
|
||||
content="Merge"
|
||||
labelPosition="right"
|
||||
icon="checkmark"
|
||||
onClick={() => mergeFaceGroups()}
|
||||
positive
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
MergeFaceGroupsModal.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
setOpen: PropTypes.func.isRequired,
|
||||
sourceFaceGroup: PropTypes.object,
|
||||
}
|
||||
|
||||
export default MergeFaceGroupsModal
|
|
@ -0,0 +1,155 @@
|
|||
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Button, Modal } from 'semantic-ui-react'
|
||||
import SelectFaceGroupTable from './SelectFaceGroupTable'
|
||||
import SelectImageFacesTable from './SelectImageFacesTable'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
|
||||
const MOVE_IMAGE_FACES_MUTATION = gql`
|
||||
mutation moveImageFaces($faceIDs: [ID!]!, $destFaceGroupID: ID!) {
|
||||
moveImageFaces(
|
||||
imageFaceIDs: $faceIDs
|
||||
destinationFaceGroupID: $destFaceGroupID
|
||||
) {
|
||||
id
|
||||
imageFaces {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
|
||||
const [selectedImageFaces, setSelectedImageFaces] = useState([])
|
||||
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
|
||||
const [imagesSelected, setImagesSelected] = useState(false)
|
||||
let history = useHistory()
|
||||
|
||||
const [moveImageFacesMutation] = useMutation(MOVE_IMAGE_FACES_MUTATION, {
|
||||
variables: {},
|
||||
refetchQueries: [
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const [loadFaceGroups, { data: faceGroupsData }] = useLazyQuery(
|
||||
MY_FACES_QUERY
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (imagesSelected) {
|
||||
loadFaceGroups()
|
||||
}
|
||||
}, [imagesSelected])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setImagesSelected(false)
|
||||
setSelectedImageFaces([])
|
||||
setSelectedFaceGroup(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (open == false) return null
|
||||
|
||||
const moveImageFaces = () => {
|
||||
const faceIDs = selectedImageFaces.map(face => face.id)
|
||||
|
||||
moveImageFacesMutation({
|
||||
variables: {
|
||||
faceIDs,
|
||||
destFaceGroupID: selectedFaceGroup.id,
|
||||
},
|
||||
}).then(() => {
|
||||
setOpen(false)
|
||||
history.push(`/people/${selectedFaceGroup.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
const imageFaces = faceGroup?.imageFaces ?? []
|
||||
|
||||
let table = null
|
||||
if (!imagesSelected) {
|
||||
table = (
|
||||
<SelectImageFacesTable
|
||||
imageFaces={imageFaces}
|
||||
selectedImageFaces={selectedImageFaces}
|
||||
setSelectedImageFaces={setSelectedImageFaces}
|
||||
title="Select images to move"
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
if (faceGroupsData) {
|
||||
const filteredFaceGroups = faceGroupsData.myFaceGroups.filter(
|
||||
x => x != faceGroup
|
||||
)
|
||||
table = (
|
||||
<SelectFaceGroupTable
|
||||
title="Select destination face group"
|
||||
faceGroups={filteredFaceGroups}
|
||||
selectedFaceGroup={selectedFaceGroup}
|
||||
setSelectedFaceGroup={setSelectedFaceGroup}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
table = <div>Loading...</div>
|
||||
}
|
||||
}
|
||||
|
||||
let positiveButton = null
|
||||
if (!imagesSelected) {
|
||||
positiveButton = (
|
||||
<Button
|
||||
disabled={selectedImageFaces.length == 0}
|
||||
content="Next"
|
||||
labelPosition="right"
|
||||
icon="arrow right"
|
||||
onClick={() => setImagesSelected(true)}
|
||||
positive
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
positiveButton = (
|
||||
<Button
|
||||
disabled={!selectedFaceGroup}
|
||||
content="Move image faces"
|
||||
labelPosition="right"
|
||||
icon="checkmark"
|
||||
onClick={() => moveImageFaces()}
|
||||
positive
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => setOpen(true)}
|
||||
open={open}
|
||||
>
|
||||
<Modal.Header>Move Image Faces</Modal.Header>
|
||||
<Modal.Content scrolling>
|
||||
<Modal.Description>
|
||||
<p>Move selected images of this face group to another face group</p>
|
||||
{table}
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
{positiveButton}
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
MoveImageFacesModal.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
setOpen: PropTypes.func.isRequired,
|
||||
faceGroup: PropTypes.object,
|
||||
}
|
||||
|
||||
export default MoveImageFacesModal
|
|
@ -0,0 +1,125 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Input, Pagination, Table } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import FaceCircleImage from '../FaceCircleImage'
|
||||
|
||||
const FaceCircleWrapper = styled.div`
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${({ $selected }) => ($selected ? `#2185c9` : 'rgba(255,255,255,0)')};
|
||||
`
|
||||
|
||||
const FlexCell = styled(Table.Cell)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
export const RowLabel = styled.span`
|
||||
${({ $selected }) => $selected && `font-weight: bold;`}
|
||||
margin-left: 12px;
|
||||
`
|
||||
|
||||
const FaceGroupRow = ({ faceGroup, faceSelected, setFaceSelected }) => {
|
||||
return (
|
||||
<Table.Row key={faceGroup.id} onClick={setFaceSelected}>
|
||||
<FlexCell>
|
||||
<FaceCircleWrapper $selected={faceSelected}>
|
||||
<FaceCircleImage imageFace={faceGroup.imageFaces[0]} size="50px" />
|
||||
</FaceCircleWrapper>
|
||||
<RowLabel $selected={faceSelected}>{faceGroup.label}</RowLabel>
|
||||
</FlexCell>
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
FaceGroupRow.propTypes = {
|
||||
faceGroup: PropTypes.object.isRequired,
|
||||
faceSelected: PropTypes.bool.isRequired,
|
||||
setFaceSelected: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
const SelectFaceGroupTable = ({
|
||||
faceGroups,
|
||||
selectedFaceGroup,
|
||||
setSelectedFaceGroup,
|
||||
title,
|
||||
}) => {
|
||||
const PAGE_SIZE = 6
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [searchValue])
|
||||
|
||||
const rows = faceGroups
|
||||
.filter(
|
||||
x =>
|
||||
searchValue == '' ||
|
||||
(x.label && x.label.toLowerCase().includes(searchValue.toLowerCase()))
|
||||
)
|
||||
.map(face => (
|
||||
<FaceGroupRow
|
||||
key={face.id}
|
||||
faceGroup={face}
|
||||
faceSelected={selectedFaceGroup == face}
|
||||
setFaceSelected={() => setSelectedFaceGroup(face)}
|
||||
/>
|
||||
))
|
||||
|
||||
const pageRows = rows.filter(
|
||||
(_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
|
||||
)
|
||||
|
||||
return (
|
||||
<Table selectable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>{title}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
icon="search"
|
||||
placeholder="Search faces..."
|
||||
fluid
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{pageRows}</Table.Body>
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<Pagination
|
||||
floated="right"
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
// nextItem={null}
|
||||
// prevItem={null}
|
||||
activePage={page + 1}
|
||||
totalPages={rows.length / PAGE_SIZE}
|
||||
onPageChange={(_, { activePage }) => {
|
||||
setPage(Math.ceil(activePage) - 1)
|
||||
}}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
SelectFaceGroupTable.propTypes = {
|
||||
faceGroups: PropTypes.array,
|
||||
selectedFaceGroup: PropTypes.object,
|
||||
setSelectedFaceGroup: PropTypes.func.isRequired,
|
||||
title: PropTypes.string,
|
||||
}
|
||||
|
||||
export default SelectFaceGroupTable
|
|
@ -0,0 +1,130 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Checkbox, Input, Pagination, Table } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import { ProtectedImage } from '../../../components/photoGallery/ProtectedMedia'
|
||||
import { RowLabel } from './SelectFaceGroupTable'
|
||||
|
||||
const SelectImagePreview = styled(ProtectedImage)`
|
||||
max-width: 120px;
|
||||
max-height: 80px;
|
||||
`
|
||||
|
||||
const ImageFaceRow = ({ imageFace, faceSelected, setFaceSelected }) => {
|
||||
return (
|
||||
<Table.Row key={imageFace.id}>
|
||||
<Table.Cell>
|
||||
<Checkbox checked={faceSelected} onChange={setFaceSelected} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<SelectImagePreview
|
||||
src={imageFace.media.thumbnail.url}
|
||||
onClick={setFaceSelected}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell width={16}>
|
||||
<RowLabel $selected={faceSelected} onClick={setFaceSelected}>
|
||||
{imageFace.media.title}
|
||||
</RowLabel>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
ImageFaceRow.propTypes = {
|
||||
imageFace: PropTypes.object.isRequired,
|
||||
faceSelected: PropTypes.bool.isRequired,
|
||||
setFaceSelected: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
const SelectImageFacesTable = ({
|
||||
imageFaces,
|
||||
selectedImageFaces,
|
||||
setSelectedImageFaces,
|
||||
title,
|
||||
}) => {
|
||||
const PAGE_SIZE = 6
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [searchValue])
|
||||
|
||||
const rows = imageFaces
|
||||
.filter(
|
||||
face =>
|
||||
searchValue == '' ||
|
||||
face.media.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
.map(face => (
|
||||
<ImageFaceRow
|
||||
key={face.id}
|
||||
imageFace={face}
|
||||
faceSelected={selectedImageFaces.includes(face)}
|
||||
setFaceSelected={() =>
|
||||
setSelectedImageFaces(faces => {
|
||||
if (faces.includes(face)) {
|
||||
return faces.filter(x => x != face)
|
||||
} else {
|
||||
return [...faces, face]
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
|
||||
const pageRows = rows.filter(
|
||||
(_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
|
||||
)
|
||||
|
||||
return (
|
||||
<Table selectable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan={3}>{title}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan={3}>
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
icon="search"
|
||||
placeholder="Search images..."
|
||||
fluid
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{pageRows}</Table.Body>
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan={3}>
|
||||
<Pagination
|
||||
floated="right"
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
// nextItem={null}
|
||||
// prevItem={null}
|
||||
activePage={page + 1}
|
||||
totalPages={rows.length / PAGE_SIZE}
|
||||
onPageChange={(_, { activePage }) => {
|
||||
setPage(Math.ceil(activePage) - 1)
|
||||
}}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
SelectImageFacesTable.propTypes = {
|
||||
imageFaces: PropTypes.array,
|
||||
selectedImageFaces: PropTypes.array,
|
||||
setSelectedImageFaces: PropTypes.func.isRequired,
|
||||
title: PropTypes.string,
|
||||
}
|
||||
|
||||
export default SelectImageFacesTable
|
|
@ -0,0 +1,47 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import PhotoGallery from '../../../components/photoGallery/PhotoGallery'
|
||||
import FaceGroupTitle from './FaceGroupTitle'
|
||||
|
||||
const SingleFaceGroup = ({ faceGroup }) => {
|
||||
const [presenting, setPresenting] = useState(false)
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
|
||||
let mediaGallery = null
|
||||
if (faceGroup) {
|
||||
const media = faceGroup.imageFaces.map(x => x.media)
|
||||
|
||||
const nextImage = () =>
|
||||
setActiveIndex(i => Math.min(i + 1, media.length - 1))
|
||||
|
||||
const previousImage = () => setActiveIndex(i => Math.max(i - 1, 0))
|
||||
|
||||
mediaGallery = (
|
||||
<div>
|
||||
<PhotoGallery
|
||||
media={media}
|
||||
loading={false}
|
||||
presenting={presenting}
|
||||
setPresenting={setPresenting}
|
||||
onSelectImage={setActiveIndex}
|
||||
activeIndex={activeIndex}
|
||||
nextImage={nextImage}
|
||||
previousImage={previousImage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FaceGroupTitle faceGroup={faceGroup} />
|
||||
{mediaGallery}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
SingleFaceGroup.propTypes = {
|
||||
faceGroup: PropTypes.object,
|
||||
}
|
||||
|
||||
export default SingleFaceGroup
|
|
@ -112,7 +112,7 @@ const MapPage = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout title="Places">
|
||||
<MapWrapper>
|
||||
<MapContainer ref={mapContainer}></MapContainer>
|
||||
</MapWrapper>
|
||||
|
|
|
@ -25,7 +25,7 @@ export const InputLabelDescription = styled.p`
|
|||
|
||||
const SettingsPage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout title="Settings">
|
||||
<ScannerSection />
|
||||
<UsersTable />
|
||||
</Layout>
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const FaceBoxStyle = styled(Link)`
|
||||
box-shadow: inset 0 0 2px 1px rgba(0, 0, 0, 0.3), 0 0 0 1px rgb(255, 255, 255);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: ${({ $minY }) => $minY * 100}%;
|
||||
bottom: ${({ $maxY }) => (1 - $maxY) * 100}%;
|
||||
left: ${({ $minX }) => $minX * 100}%;
|
||||
right: ${({ $maxX }) => (1 - $maxX) * 100}%;
|
||||
`
|
||||
|
||||
const FaceBox = ({ face /*media*/ }) => {
|
||||
return (
|
||||
<FaceBoxStyle
|
||||
to={`/people/${face.faceGroup.id}`}
|
||||
$minX={face.rectangle.minX}
|
||||
$maxX={face.rectangle.maxX}
|
||||
$minY={face.rectangle.minY}
|
||||
$maxY={face.rectangle.maxY}
|
||||
></FaceBoxStyle>
|
||||
)
|
||||
}
|
||||
|
||||
FaceBox.propTypes = {
|
||||
face: PropTypes.object.isRequired,
|
||||
media: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
const SidebarFacesOverlayWrapper = styled.div`
|
||||
position: absolute;
|
||||
width: ${({ width }) => width * 100}%;
|
||||
left: ${({ width }) => (100 - width * 100) / 2}%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
|
||||
user-select: none;
|
||||
transition: opacity ease 200ms;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
export const SidebarFacesOverlay = ({ media }) => {
|
||||
if (media.type != 'photo') return null
|
||||
|
||||
const faceBoxes = media.faces?.map(face => (
|
||||
<FaceBox key={face.id} face={face} media={media} />
|
||||
))
|
||||
|
||||
let wrapperWidth = 1
|
||||
if (media.thumbnail.width * 0.75 < media.thumbnail.height) {
|
||||
wrapperWidth = (media.thumbnail.width * 0.75) / media.thumbnail.height
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarFacesOverlayWrapper width={wrapperWidth}>
|
||||
{faceBoxes}
|
||||
</SidebarFacesOverlayWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
SidebarFacesOverlay.propTypes = {
|
||||
media: PropTypes.object.isRequired,
|
||||
}
|
|
@ -14,6 +14,7 @@ const AlbumPage = React.lazy(() => import('../../Pages/AlbumPage/AlbumPage'))
|
|||
const PhotosPage = React.lazy(() => import('../../Pages/PhotosPage/PhotosPage'))
|
||||
const PlacesPage = React.lazy(() => import('../../Pages/PlacesPage/PlacesPage'))
|
||||
const SharePage = React.lazy(() => import('../../Pages/SharePage/SharePage'))
|
||||
const PeoplePage = React.lazy(() => import('../../Pages/PeoplePage/PeoplePage'))
|
||||
|
||||
const LoginPage = React.lazy(() => import('../../Pages/LoginPage/LoginPage'))
|
||||
const InitialSetupPage = React.lazy(() =>
|
||||
|
@ -44,9 +45,10 @@ const Routes = () => {
|
|||
<Route path="/initialSetup" component={InitialSetupPage} />
|
||||
<Route path="/share" component={SharePage} />
|
||||
<AuthorizedRoute exact path="/albums" component={AlbumsPage} />
|
||||
<AuthorizedRoute path="/album/:id/:subPage?" component={AlbumPage} />
|
||||
<AuthorizedRoute path="/photos/:subPage?" component={PhotosPage} />
|
||||
<AuthorizedRoute path="/album/:id" component={AlbumPage} />
|
||||
<AuthorizedRoute path="/photos" component={PhotosPage} />
|
||||
<AuthorizedRoute path="/places" component={PlacesPage} />
|
||||
<AuthorizedRoute path="/people/:person?" component={PeoplePage} />
|
||||
<AuthorizedRoute admin path="/settings" component={SettingsPage} />
|
||||
<Route path="/" exact render={() => <Redirect to="/photos" />} />
|
||||
<Route render={() => <div>Page not found</div>} />
|
||||
|
|
|
@ -7,6 +7,7 @@ import { ProtectedImage, ProtectedVideo } from '../photoGallery/ProtectedMedia'
|
|||
import SidebarShare from './Sharing'
|
||||
import SidebarDownload from './SidebarDownload'
|
||||
import SidebarItem from './SidebarItem'
|
||||
import { SidebarFacesOverlay } from '../facesOverlay/FacesOverlay'
|
||||
|
||||
const mediaQuery = gql`
|
||||
query sidebarPhoto($id: ID!) {
|
||||
|
@ -41,6 +42,7 @@ const mediaQuery = gql`
|
|||
audio
|
||||
}
|
||||
exif {
|
||||
id
|
||||
camera
|
||||
maker
|
||||
lens
|
||||
|
@ -52,6 +54,18 @@ const mediaQuery = gql`
|
|||
flash
|
||||
exposureProgram
|
||||
}
|
||||
faces {
|
||||
id
|
||||
rectangle {
|
||||
minX
|
||||
maxX
|
||||
minY
|
||||
maxY
|
||||
}
|
||||
faceGroup {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -203,6 +217,7 @@ const SidebarContent = ({ media, hidePreview }) => {
|
|||
{!hidePreview && (
|
||||
<PreviewImageWrapper imageAspect={imageAspect}>
|
||||
<PreviewMedia previewImage={previewImage} media={media} />
|
||||
<SidebarFacesOverlay media={media} />
|
||||
</PreviewImageWrapper>
|
||||
)}
|
||||
<Name>{media && media.title}</Name>
|
||||
|
|
Loading…
Reference in New Issue