1
Fork 0

Work on face merge ui

This commit is contained in:
viktorstrate 2021-02-19 17:49:41 +01:00
parent 6b48ac9a16
commit 00fceea4db
No known key found for this signature in database
GPG Key ID: 3F855605109C1E8A
6 changed files with 651 additions and 114 deletions

View File

@ -2,10 +2,11 @@ package resolvers
import (
"context"
"errors"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
@ -74,42 +75,51 @@ func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID in
return nil, errors.New("unauthorized")
}
if err := user.FillAlbums(r.Database); err != nil {
faceGroup, err := userOwnedFaceGroup(r.Database, user, faceGroupID)
if 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 := r.Database.
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 := r.Database.
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 := r.Database.Model(faceGroup).Update("label", label).Error; err != nil {
return nil, err
}
if err := r.Database.Model(&faceGroup).Update("label", label).Error; err != nil {
return nil, err
}
return &faceGroup, nil
return faceGroup, nil
}
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) {
panic("not implemented")
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
}
return destinationFaceGroup, nil
}
func (r *mutationResolver) MoveImageFace(ctx context.Context, imageFaceID int, newFaceGroupID int) (*models.ImageFace, error) {
@ -119,3 +129,46 @@ func (r *mutationResolver) MoveImageFace(ctx context.Context, imageFaceID int, n
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
panic("not implemented")
}
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
}

View File

@ -0,0 +1,97 @@
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' }) => {
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.isRequired,
selectable: PropTypes.bool,
size: PropTypes.string,
}
export default FaceCircleImage

View File

@ -1,13 +1,14 @@
import React from 'react'
import React, { createRef, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { gql, useQuery } from '@apollo/client'
import { gql, useMutation, useQuery } from '@apollo/client'
import Layout from '../../Layout'
import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
import SingleFaceGroup from './SingleFaceGroup'
import SingleFaceGroup from './SingleFaceGroup/SingleFaceGroup'
import { Icon, Input } from 'semantic-ui-react'
import FaceCircleImage from './FaceCircleImage'
const MY_FACES_QUERY = gql`
export const MY_FACES_QUERY = gql`
query myFaces {
myFaceGroups {
id
@ -33,81 +34,145 @@ const MY_FACES_QUERY = gql`
}
`
const CircleImageWrapper = styled.div`
background-color: #eee;
position: relative;
border-radius: 50%;
width: 150px;
height: 150px;
object-fit: fill;
margin: 12px;
overflow: hidden;
export const SET_GROUP_LABEL_MUTATION = gql`
mutation($groupID: ID!, $label: String) {
setFaceGroupLabel(faceGroupID: $groupID, label: $label) {
id
label
}
}
`
const FaceImagePortrait = styled(ProtectedImage)`
position: absolute;
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)})`};
transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`};
object-fit: cover;
`
const FaceImageLandscape = styled(ProtectedImage)`
position: absolute;
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)})`};
transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`};
object-fit: cover;
`
const FaceLabel = styled.div`
const FaceDetailsButton = styled.button`
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
margin: 12px 12px 24px;
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]
const rect = previewFace.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 FaceImage =
previewFace.media.thumbnail.width > previewFace.media.thumbnail.height
? FaceImageLandscape
: FaceImagePortrait
return (
<Link to={`/people/${group.id}`}>
<CircleImageWrapper>
<FaceImage
scale={scale}
origin={origin}
src={previewFace.media.thumbnail.url}
/>
</CircleImageWrapper>
<FaceLabel labeled={!!group.label}>
{group.label ?? 'Unlabeled'}
</FaceLabel>
</Link>
<div style={{ margin: '12px' }}>
<Link to={`/people/${group.id}`}>
<FaceCircleImage imageFace={previewFace} selectable />
</Link>
<FaceDetails group={group} />
</div>
)
}

View File

@ -0,0 +1,141 @@
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 MergeFaceGroupsModal from './MergeFaceGroupsModal'
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 [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 ungroup"
text="Merge Face"
alt="Merge this group into another"
onClick={() => setMergeModalOpen(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}
/>
</>
)
}
FaceGroupTitle.propTypes = {
faceGroup: PropTypes.object,
}
export default FaceGroupTitle

View File

@ -0,0 +1,182 @@
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 { Redirect } from 'react-router-dom'
const COMBINE_FACES_MUTATION = gql`
mutation($destID: ID!, $srcID: ID!) {
combineFaceGroups(
destinationFaceGroupID: $destID
sourceFaceGroupID: $srcID
) {
id
}
}
`
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 [selectedRow, setSelectedRow] = useState(null)
const [mergedFaceGroup, setMergedFaceGroup] = useState(false)
const PAGE_SIZE = 8
const { data } = useQuery(MY_FACES_QUERY)
const [combineFacesMutation] = useMutation(COMBINE_FACES_MUTATION, {
variables: {
srcID: sourceFaceGroup.id,
},
refetchQueries: [
{
query: MY_FACES_QUERY,
},
],
})
const mergeFaceGroups = () => {
const destFaceGroup = data.myFaceGroups.filter(
x => x.id != sourceFaceGroup.id
)[selectedRow]
combineFacesMutation({
variables: {
destID: destFaceGroup.id,
},
onCompleted() {
setMergedFaceGroup(destFaceGroup.id)
},
})
}
if (mergedFaceGroup) {
return <Redirect to={`/people/${mergedFaceGroup}`} />
}
const rows = data?.myFaceGroups
.filter(x => x.id != sourceFaceGroup.id)
.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)}
onOpen={() => setOpen(true)}
open={open}
>
<Modal.Header>Merge Face Groups</Modal.Header>
<Modal.Content>
<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 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>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={selectedRow == 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.isRequired,
}
export default MergeFaceGroupsModal

View File

@ -1,7 +1,8 @@
import React from 'react'
import PropTypes from 'prop-types'
import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
import PhotoGallery from '../../components/photoGallery/PhotoGallery'
import React from 'react'
import PhotoGallery from '../../../components/photoGallery/PhotoGallery'
import { ProtectedImage } from '../../../components/photoGallery/ProtectedMedia'
import FaceGroupTitle from './FaceGroupTitle'
const ImageFace = ({ imageFace }) => {
return (
@ -17,19 +18,10 @@ ImageFace.propTypes = {
}
const SingleFaceGroup = ({ faceGroup }) => {
if (!faceGroup) {
return null
}
// const images = faceGroup.imageFaces.map(imgFace => (
// <ImageFace key={imgFace.id} imageFace={imgFace} />
// ))
const media = faceGroup.imageFaces.map(x => x.media)
return (
<div>
Face group: {faceGroup.id}
let mediaGallery = null
if (faceGroup) {
const media = faceGroup.imageFaces.map(x => x.media)
mediaGallery = (
<div>
<PhotoGallery
media={media}
@ -38,6 +30,13 @@ const SingleFaceGroup = ({ faceGroup }) => {
onSelectImage={() => {}}
/>
</div>
)
}
return (
<div>
<FaceGroupTitle faceGroup={faceGroup} />
{mediaGallery}
</div>
)
}