Add UI to move faces between face groups
This commit is contained in:
parent
a3e5346501
commit
20251dedd6
|
@ -150,7 +150,83 @@ func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFac
|
|||
}
|
||||
|
||||
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
|
||||
panic("not implemented")
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 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) {
|
||||
|
|
|
@ -12,11 +12,12 @@ import (
|
|||
)
|
||||
|
||||
type FaceDetector struct {
|
||||
mutex sync.Mutex
|
||||
db *gorm.DB
|
||||
rec *face.Recognizer
|
||||
samples []face.Descriptor
|
||||
cats []int32
|
||||
mutex sync.Mutex
|
||||
db *gorm.DB
|
||||
rec *face.Recognizer
|
||||
faceDescriptors []face.Descriptor
|
||||
faceGroupIDs []int32
|
||||
imageFaceIDs []int
|
||||
}
|
||||
|
||||
var GlobalFaceDetector FaceDetector
|
||||
|
@ -30,22 +31,23 @@ func InitializeFaceDetector(db *gorm.DB) error {
|
|||
return errors.Wrap(err, "initialize facedetect recognizer")
|
||||
}
|
||||
|
||||
samples, cats, err := getSamplesFromDatabase(db)
|
||||
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,
|
||||
samples: samples,
|
||||
cats: cats,
|
||||
db: db,
|
||||
rec: rec,
|
||||
faceDescriptors: faceDescriptors,
|
||||
faceGroupIDs: faceGroupIDs,
|
||||
imageFaceIDs: imageFaceIDs,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSamplesFromDatabase(db *gorm.DB) (samples []face.Descriptor, cats []int32, err error) {
|
||||
func getSamplesFromDatabase(db *gorm.DB) (samples []face.Descriptor, faceGroupIDs []int32, imageFaceIDs []int, err error) {
|
||||
|
||||
var imageFaces []*models.ImageFace
|
||||
|
||||
|
@ -54,11 +56,13 @@ func getSamplesFromDatabase(db *gorm.DB) (samples []face.Descriptor, cats []int3
|
|||
}
|
||||
|
||||
samples = make([]face.Descriptor, len(imageFaces))
|
||||
cats = make([]int32, len(imageFaces))
|
||||
faceGroupIDs = make([]int32, len(imageFaces))
|
||||
imageFaceIDs = make([]int, len(imageFaces))
|
||||
|
||||
for i, imgFace := range imageFaces {
|
||||
samples[i] = face.Descriptor(imgFace.Descriptor)
|
||||
cats[i] = int32(imgFace.FaceGroupID)
|
||||
faceGroupIDs[i] = int32(imgFace.FaceGroupID)
|
||||
imageFaceIDs[i] = imgFace.ID
|
||||
}
|
||||
|
||||
return
|
||||
|
@ -150,10 +154,11 @@ func (fd *FaceDetector) classifyFace(face *face.Face, media *models.Media, image
|
|||
}
|
||||
}
|
||||
|
||||
fd.samples = append(fd.samples, face.Descriptor)
|
||||
fd.cats = append(fd.cats, int32(faceGroup.ID))
|
||||
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.samples, fd.cats)
|
||||
fd.rec.SetSamples(fd.faceDescriptors, fd.faceGroupIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -161,19 +166,37 @@ func (fd *FaceDetector) MergeCategories(sourceID int32, destID int32) {
|
|||
fd.mutex.Lock()
|
||||
defer fd.mutex.Unlock()
|
||||
|
||||
for i := range fd.cats {
|
||||
if fd.cats[i] == sourceID {
|
||||
fd.cats[i] = destID
|
||||
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) {
|
||||
unrecognizedSamples := make([]face.Descriptor, 0)
|
||||
unrecognizedCats := make([]int32, 0)
|
||||
unrecognizedDescriptors := make([]face.Descriptor, 0)
|
||||
unrecognizedFaceGroupIDs := make([]int32, 0)
|
||||
unrecognizedImageFaceIDs := make([]int, 0)
|
||||
|
||||
newCats := make([]int32, 0)
|
||||
newSamples := make([]face.Descriptor, 0)
|
||||
newFaceGroupIDs := make([]int32, 0)
|
||||
newDescriptors := make([]face.Descriptor, 0)
|
||||
newImageFaceIDs := make([]int, 0)
|
||||
|
||||
var unlabeledFaceGroups []*models.FaceGroup
|
||||
|
||||
|
@ -193,59 +216,64 @@ func (fd *FaceDetector) RecognizeUnlabeledFaces(tx *gorm.DB, user *models.User)
|
|||
fd.mutex.Lock()
|
||||
defer fd.mutex.Unlock()
|
||||
|
||||
for i := range fd.samples {
|
||||
cat := fd.cats[i]
|
||||
sample := fd.samples[i]
|
||||
for i := range fd.faceDescriptors {
|
||||
descriptor := fd.faceDescriptors[i]
|
||||
faceGroupID := fd.faceGroupIDs[i]
|
||||
imageFaceID := fd.imageFaceIDs[i]
|
||||
|
||||
catIsUnlabeled := false
|
||||
isUnlabeled := false
|
||||
for _, unlabeledFaceGroup := range unlabeledFaceGroups {
|
||||
if cat == int32(unlabeledFaceGroup.ID) {
|
||||
catIsUnlabeled = true
|
||||
if faceGroupID == int32(unlabeledFaceGroup.ID) {
|
||||
isUnlabeled = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if catIsUnlabeled {
|
||||
unrecognizedCats = append(unrecognizedCats, cat)
|
||||
unrecognizedSamples = append(unrecognizedSamples, sample)
|
||||
if isUnlabeled {
|
||||
unrecognizedFaceGroupIDs = append(unrecognizedFaceGroupIDs, faceGroupID)
|
||||
unrecognizedDescriptors = append(unrecognizedDescriptors, descriptor)
|
||||
unrecognizedImageFaceIDs = append(unrecognizedImageFaceIDs, imageFaceID)
|
||||
} else {
|
||||
newCats = append(newCats, cat)
|
||||
newSamples = append(newSamples, sample)
|
||||
newFaceGroupIDs = append(newFaceGroupIDs, faceGroupID)
|
||||
newDescriptors = append(newDescriptors, descriptor)
|
||||
newImageFaceIDs = append(newImageFaceIDs, imageFaceID)
|
||||
}
|
||||
}
|
||||
|
||||
fd.cats = newCats
|
||||
fd.samples = newSamples
|
||||
fd.faceGroupIDs = newFaceGroupIDs
|
||||
fd.faceDescriptors = newDescriptors
|
||||
fd.imageFaceIDs = newImageFaceIDs
|
||||
|
||||
updatedImageFaces := make([]*models.ImageFace, 0)
|
||||
|
||||
for i := range unrecognizedSamples {
|
||||
cat := unrecognizedCats[i]
|
||||
sample := unrecognizedSamples[i]
|
||||
for i := range unrecognizedDescriptors {
|
||||
descriptor := unrecognizedDescriptors[i]
|
||||
faceGroupID := unrecognizedFaceGroupIDs[i]
|
||||
imageFaceID := unrecognizedImageFaceIDs[i]
|
||||
|
||||
match := fd.classifyDescriptor(sample)
|
||||
match := fd.classifyDescriptor(descriptor)
|
||||
|
||||
if match < 0 {
|
||||
// still no match, we can readd it to the list
|
||||
fd.cats = append(fd.cats, cat)
|
||||
fd.samples = append(fd.samples, sample)
|
||||
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{
|
||||
Descriptor: models.FaceDescriptor(sample),
|
||||
}).First(imageFace).Error; err != nil {
|
||||
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(cat)).Error; err != nil {
|
||||
if err := tx.Model(&imageFace).Update("face_group_id", int(faceGroupID)).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedImageFaces = append(updatedImageFaces, &imageFace)
|
||||
|
||||
fd.cats = append(fd.cats, match)
|
||||
fd.samples = append(fd.samples, sample)
|
||||
fd.faceGroupIDs = append(fd.faceGroupIDs, match)
|
||||
fd.faceDescriptors = append(fd.faceDescriptors, descriptor)
|
||||
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFaceID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
|
@ -88,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)),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -63,6 +63,10 @@ const CircleImageWrapper = styled.div`
|
|||
`
|
||||
|
||||
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))
|
||||
|
@ -89,7 +93,7 @@ const FaceCircleImage = ({ imageFace, selectable, size = '150px' }) => {
|
|||
}
|
||||
|
||||
FaceCircleImage.propTypes = {
|
||||
imageFace: PropTypes.object.isRequired,
|
||||
imageFace: PropTypes.object,
|
||||
selectable: PropTypes.bool,
|
||||
size: PropTypes.string,
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ export const MY_FACES_QUERY = gql`
|
|||
media {
|
||||
id
|
||||
type
|
||||
title
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Dropdown, Input } from 'semantic-ui-react'
|
|||
import styled from 'styled-components'
|
||||
import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
|
||||
import MergeFaceGroupsModal from './MergeFaceGroupsModal'
|
||||
import MoveImageFacesModal from './MoveImageFacesModal'
|
||||
|
||||
const TitleWrapper = styled.div`
|
||||
min-height: 3.5em;
|
||||
|
@ -31,6 +32,7 @@ const FaceGroupTitle = ({ faceGroup }) => {
|
|||
const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
|
||||
const inputRef = createRef()
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false)
|
||||
const [moveModalOpen, setMoveModalOpen] = useState(false)
|
||||
|
||||
const [setGroupLabel, { loading: setLabelLoading }] = useMutation(
|
||||
SET_GROUP_LABEL_MUTATION,
|
||||
|
@ -94,11 +96,20 @@ const FaceGroupTitle = ({ faceGroup }) => {
|
|||
onClick={() => setEditLabel(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="object ungroup"
|
||||
icon="object group"
|
||||
text="Merge Face"
|
||||
alt="Merge this group into another"
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="object ungroup"
|
||||
text="Detach Faces"
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="clone"
|
||||
text="Move Faces"
|
||||
onClick={() => setMoveModalOpen(true)}
|
||||
/>
|
||||
</Dropdown.Menu>
|
||||
</TitleDropdown>
|
||||
</TitleWrapper>
|
||||
|
@ -130,6 +141,11 @@ const FaceGroupTitle = ({ faceGroup }) => {
|
|||
setOpen={setMergeModalOpen}
|
||||
sourceFaceGroup={faceGroup}
|
||||
/>
|
||||
<MoveImageFacesModal
|
||||
open={moveModalOpen}
|
||||
setOpen={setMoveModalOpen}
|
||||
faceGroup={faceGroup}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,18 +1,10 @@
|
|||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Header,
|
||||
Table,
|
||||
Input,
|
||||
Pagination,
|
||||
} from 'semantic-ui-react'
|
||||
import FaceCircleImage from '../FaceCircleImage'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import styled from 'styled-components'
|
||||
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!) {
|
||||
|
@ -25,53 +17,9 @@ const COMBINE_FACES_MUTATION = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const FaceCircleWrapper = styled.div`
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${({ $selected }) => ($selected ? `#2185c9` : 'rgba(255,255,255,0)')};
|
||||
`
|
||||
|
||||
const FaceGroupRowStyled = styled(Table.Row)``
|
||||
|
||||
const FaceGroupCell = styled(Table.Cell)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const RowLabel = styled.span`
|
||||
${({ $selected }) => $selected && `font-weight: bold;`}
|
||||
margin-left: 12px;
|
||||
`
|
||||
|
||||
const FaceGroupRow = ({ faceGroup, faceSelected, setFaceSelected }) => {
|
||||
return (
|
||||
<FaceGroupRowStyled
|
||||
$selected={faceSelected}
|
||||
key={faceGroup.id}
|
||||
onClick={setFaceSelected}
|
||||
>
|
||||
<FaceGroupCell>
|
||||
<FaceCircleWrapper $selected={faceSelected}>
|
||||
<FaceCircleImage imageFace={faceGroup.imageFaces[0]} size="50px" />
|
||||
</FaceCircleWrapper>
|
||||
<RowLabel $selected={faceSelected}>{faceGroup.label}</RowLabel>
|
||||
</FaceGroupCell>
|
||||
</FaceGroupRowStyled>
|
||||
)
|
||||
}
|
||||
|
||||
FaceGroupRow.propTypes = {
|
||||
faceGroup: PropTypes.object.isRequired,
|
||||
faceSelected: PropTypes.bool.isRequired,
|
||||
setFaceSelected: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
const MergeFaceGroupsModal = ({ open, setOpen, sourceFaceGroup }) => {
|
||||
const [page, setPage] = useState(0)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [selectedRow, setSelectedRow] = useState(null)
|
||||
const PAGE_SIZE = 6
|
||||
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
|
||||
|
||||
let history = useHistory()
|
||||
const { data } = useQuery(MY_FACES_QUERY)
|
||||
const [combineFacesMutation] = useMutation(COMBINE_FACES_MUTATION, {
|
||||
|
@ -88,40 +36,19 @@ const MergeFaceGroupsModal = ({ open, setOpen, sourceFaceGroup }) => {
|
|||
if (open == false) return null
|
||||
|
||||
const filteredFaceGroups =
|
||||
data?.myFaceGroups
|
||||
.filter(
|
||||
x =>
|
||||
searchValue == '' ||
|
||||
(x.label && x.label.toLowerCase().includes(searchValue.toLowerCase()))
|
||||
)
|
||||
.filter(x => x.id != sourceFaceGroup?.id) ?? []
|
||||
|
||||
console.log(filteredFaceGroups)
|
||||
data?.myFaceGroups.filter(x => x.id != sourceFaceGroup?.id) ?? []
|
||||
|
||||
const mergeFaceGroups = () => {
|
||||
const destFaceGroup = filteredFaceGroups[selectedRow]
|
||||
|
||||
combineFacesMutation({
|
||||
variables: {
|
||||
destID: destFaceGroup.id,
|
||||
destID: selectedFaceGroup.id,
|
||||
},
|
||||
}).then(() => {
|
||||
setOpen(false)
|
||||
history.push(`/people/${destFaceGroup.id}`)
|
||||
history.push(`/people/${selectedFaceGroup.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
const rows = filteredFaceGroups
|
||||
.filter((_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE)
|
||||
.map((face, i) => (
|
||||
<FaceGroupRow
|
||||
key={face.id}
|
||||
faceGroup={face}
|
||||
faceSelected={selectedRow == i + page * PAGE_SIZE}
|
||||
setFaceSelected={() => setSelectedRow(i + page * PAGE_SIZE)}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => setOpen(false)}
|
||||
|
@ -129,52 +56,24 @@ const MergeFaceGroupsModal = ({ open, setOpen, sourceFaceGroup }) => {
|
|||
open={open}
|
||||
>
|
||||
<Modal.Header>Merge Face Groups</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Modal.Content scrolling>
|
||||
<Modal.Description>
|
||||
<Header>Select the destination face below</Header>
|
||||
<Table selectable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Face group</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>{rows}</Table.Body>
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<Pagination
|
||||
floated="right"
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
// nextItem={null}
|
||||
// prevItem={null}
|
||||
activePage={page + 1}
|
||||
totalPages={data?.myFaceGroups.length / PAGE_SIZE}
|
||||
onPageChange={(_, { activePage }) => {
|
||||
setPage(Math.ceil(activePage) - 1)
|
||||
}}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
<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={selectedRow == null}
|
||||
disabled={selectedFaceGroup == null}
|
||||
content="Merge"
|
||||
labelPosition="right"
|
||||
icon="checkmark"
|
||||
|
|
|
@ -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
|
|
@ -1,22 +1,8 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import PhotoGallery from '../../../components/photoGallery/PhotoGallery'
|
||||
import { ProtectedImage } from '../../../components/photoGallery/ProtectedMedia'
|
||||
import FaceGroupTitle from './FaceGroupTitle'
|
||||
|
||||
const ImageFace = ({ imageFace }) => {
|
||||
return (
|
||||
<div>
|
||||
Image face: {imageFace.id}
|
||||
<ProtectedImage src={imageFace.media.thumbnail.url} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ImageFace.propTypes = {
|
||||
imageFace: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
const SingleFaceGroup = ({ faceGroup }) => {
|
||||
const [presenting, setPresenting] = useState(false)
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
|
|
Loading…
Reference in New Issue