diff options
author | Marcel Klehr <mklehr@gmx.net> | 2022-08-16 14:07:23 +0300 |
---|---|---|
committer | Marcel Klehr <mklehr@gmx.net> | 2022-08-23 15:30:18 +0300 |
commit | 0723f152afdbf3f24b07e97446fd10e3c31e604d (patch) | |
tree | f91c6b35e23466f0f3688c4604bbd76eafef205f | |
parent | df59eaa668028f3d5804666c93789e29ac36e28e (diff) |
Implement merging faces
Signed-off-by: Marcel Klehr <mklehr@gmx.net>
-rw-r--r-- | src/components/FaceCover.vue | 29 | ||||
-rw-r--r-- | src/components/FaceMergeForm.vue | 118 | ||||
-rw-r--r-- | src/mixins/FaceCoverMixin.js | 72 | ||||
-rw-r--r-- | src/store/faces.js | 32 | ||||
-rw-r--r-- | src/views/FaceContent.vue | 37 |
5 files changed, 245 insertions, 43 deletions
diff --git a/src/components/FaceCover.vue b/src/components/FaceCover.vue index ae14ef97..4354daa0 100644 --- a/src/components/FaceCover.vue +++ b/src/components/FaceCover.vue @@ -42,16 +42,17 @@ </template> <script> -import he from 'he' import { mapGetters } from 'vuex' import { generateUrl } from '@nextcloud/router' import FetchFacesMixin from '../mixins/FetchFacesMixin.js' +import FaceCoverMixin from '../mixins/FaceCoverMixin.js' export default { name: 'FaceCover', mixins: [ FetchFacesMixin, + FaceCoverMixin, ], props: { @@ -87,34 +88,12 @@ export default { }, cover() { - return (this.facesFiles[this.face.basename] || []) - .slice(0, 25) - .map(fileId => this.files[fileId]) - .map(file => ({ ...file, faceDetections: JSON.parse(he.decode(file.faceDetections)) })) - // sort larges face first - .sort((a, b) => - b.faceDetections.find(d => d.faceName === this.face.basename).width - - a.faceDetections.find(d => d.faceName === this.face.basename).width - ) - // sort fewest face detections first - .sort((a, b) => - a.faceDetections.length - - b.faceDetections.length - )[0] + return this.getFaceCover(this.face.basename) }, coverDimensions() { if (!this.cover) return {} - const detections = this.cover.faceDetections - - const detection = detections.find(detection => detection.faceName === this.face.basename) - const zoom = Math.max(1, (1 / detection.width) * 0.4) - - return { - width: '100%', - transform: `translate(calc( 125px - ${(detection.x + detection.width / 2) * 100}% ), calc( 125px - ${(detection.y + detection.height / 2) * 100}% )) scale(${zoom})`, - transformOrigin: `${(detection.x + detection.width / 2) * 100}% ${(detection.y + detection.height / 2) * 100}%`, - } + return this.getCoverStyle(this.face.basename, 250) }, }, diff --git a/src/components/FaceMergeForm.vue b/src/components/FaceMergeForm.vue new file mode 100644 index 00000000..8f3aa48d --- /dev/null +++ b/src/components/FaceMergeForm.vue @@ -0,0 +1,118 @@ +<template> + <div class="merge-form face-list"> + <template v-if="loading"> + <Loader class="loader" /> + </template> + <template v-else> + <div v-for="face in filteredFaces" + :key="face.basename" + class="face-list__item" + @click="handleSelect(face.basename)"> + <div class="face-list__item__crop-container"> + <img class="face-list__item__image" + :src="getCoverUrl(face.basename)" + :style="getCoverStyle(face.basename, 50)"> + </div> + <div class="face-list__item__details"> + {{ face.basename }} + </div> + </div> + </template> + </div> +</template> + +<script> +import FaceCoverMixin from '../mixins/FaceCoverMixin.js' +import { generateUrl } from '@nextcloud/router' +import { mapGetters } from 'vuex' +import FetchFacesMixin from '../mixins/FetchFacesMixin.js' +import Loader from './Loader' + +export default { + name: 'FaceMergeForm', + components: { Loader }, + mixins: [ + FaceCoverMixin, + FetchFacesMixin, + ], + props: { + firstFace: { + type: String, + required: true, + }, + }, + data() { + return { + loading: false, + } + }, + computed: { + ...mapGetters([ + 'files', + 'faces', + 'facesFiles', + ]), + + filteredFaces() { + return Object.values(this.faces).filter(face => face.basename !== this.firstFace) + }, + }, + methods: { + getCoverUrl(faceName) { + const cover = this.getFaceCover(faceName) + if (!cover) { + this.fetchFaceContent(faceName) + return '' + } + return generateUrl(`/core/preview?fileId=${cover.fileid}&x=${512}&y=${512}&forceIcon=0&a=1`) + }, + + handleSelect(faceName) { + this.$emit('select', faceName) + this.loading = true + }, + }, +} +</script> + +<style scoped lang="scss"> +.face-list { + display: flex; + flex-direction: column; + height: 350px; + + &__item { + display: flex; + flex-direction: row; + padding: 10px; + border-radius: var(--border-radius); + align-items: center; + cursor: pointer; + + * { + cursor: pointer; + } + + &__crop-container { + overflow: hidden; + width: 50px; + height: 50px; + border-radius: 50px; + position: relative; + background: var(--color-background-darker); + } + + &:hover { + background: var(--color-background-hover); + } + + &__details { + padding: 10px; + } + } +} + +.loader { + margin-top: 25%; +} +</style> diff --git a/src/mixins/FaceCoverMixin.js b/src/mixins/FaceCoverMixin.js new file mode 100644 index 00000000..c5fe1f52 --- /dev/null +++ b/src/mixins/FaceCoverMixin.js @@ -0,0 +1,72 @@ +/** + * @copyright Copyright (c) 2022 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import { mapGetters } from 'vuex' +import he from 'he' + +export default { + name: 'FaceCoverMixin', + + computed: { + ...mapGetters([ + 'faces', + 'facesFiles', + 'files', + ]), + }, + + methods: { + getFaceCover(faceName) { + return (this.facesFiles[faceName] || []) + .slice(0, 25) + .map(fileId => this.files[fileId]) + .map(file => ({ ...file, faceDetections: JSON.parse(he.decode(file.faceDetections)) })) + // sort larges face first + .sort((a, b) => + b.faceDetections.find(d => d.faceName === faceName).width + - a.faceDetections.find(d => d.faceName === faceName).width + ) + // sort fewest face detections first + .sort((a, b) => + a.faceDetections.length + - b.faceDetections.length + )[0] + }, + + getCoverStyle(faceName, baseWidth) { + const cover = this.getFaceCover(faceName) + if (!cover) { + return {} + } + const detections = cover.faceDetections + + const detection = detections.find(detection => detection.faceName === faceName) + const zoom = Math.max(1, (1 / detection.width) * 0.4) + + return { + width: '100%', + transform: `translate(calc( ${baseWidth / 2}px - ${(detection.x + detection.width / 2) * 100}% ), calc( ${baseWidth / 2}px - ${(detection.y + detection.height / 2) * 100}% )) scale(${zoom})`, + transformOrigin: `${(detection.x + detection.width / 2) * 100}% ${(detection.y + detection.height / 2) * 100}%`, + } + }, + }, +} diff --git a/src/store/faces.js b/src/store/faces.js index fc1719ac..65a2260f 100644 --- a/src/store/faces.js +++ b/src/store/faces.js @@ -106,7 +106,7 @@ const actions = { * * @param {object} context vuex context * @param {object} data destructuring object - * @param {Album[]} data.faces list of faces + * @param {Face[]} data.faces list of faces */ addFaces(context, { faces }) { context.commit('addFaces', { faces }) @@ -117,32 +117,32 @@ const actions = { * * @param {object} context vuex context * @param {object} data destructuring object - * @param {string} data.albumName the album name - * @param {string[]} data.fileIdsToAdd list of files ids to add + * @param {string} data.faceName the new face name + * @param {string} data.faceName the album name + * @param {string[]} data.fileIdsToMove list of files ids to move + * @param data.oldFace */ - async addFilesToAlbum(context, { albumName, fileIdsToAdd }) { + async moveFilesToFace(context, { oldFace, faceName, fileIdsToMove }) { const semaphore = new Semaphore(5) - context.commit('addFilesToAlbum', { albumName, fileIdsToAdd }) - - const promises = fileIdsToAdd + const promises = fileIdsToMove .map(async (fileId) => { - const fileName = context.getters.files[fileId].filename const fileBaseName = context.getters.files[fileId].basename const symbol = await semaphore.acquire() try { - await client.copyFile( - `/files/${getCurrentUser()?.uid}/${fileName}`, - `/photos/${getCurrentUser()?.uid}/albums/${albumName}/${fileBaseName}` + await client.moveFile( + `/recognize/${getCurrentUser()?.uid}/faces/${oldFace}/${fileBaseName}`, + `/recognize/${getCurrentUser()?.uid}/faces/${faceName}/${fileBaseName}` ) + await context.commit('addFilesToFace', { faceName, fileIdsToAdd: [fileId] }) + await context.commit('removeFilesFromFace', { faceName: oldFace, fileIdsToRemove: [fileId] }) + semaphore.release(symbol) } catch (error) { - context.commit('removeFilesFromAlbum', { albumName, fileIdsToRemove: [fileId] }) - - logger.error(t('photos', 'Failed to add {fileBaseName} to album {albumName}.', { fileBaseName, albumName }), error) - showError(t('photos', 'Failed to add {fileBaseName} to album {albumName}.', { fileBaseName, albumName })) - } finally { + logger.error(t('photos', 'Failed to move {fileBaseName} to person {faceName}.', { fileBaseName, faceName }), error) + showError(t('photos', 'Failed to move {fileBaseName} to person {faceName}.', { fileBaseName, faceName })) semaphore.release(symbol) + throw error } }) diff --git a/src/views/FaceContent.vue b/src/views/FaceContent.vue index db144d14..b37876f6 100644 --- a/src/views/FaceContent.vue +++ b/src/views/FaceContent.vue @@ -56,6 +56,15 @@ <Pencil /> </template> </ActionButton> + <ActionButton v-if="Object.keys(faces).length > 1" + :close-after-click="true" + :aria-label="t('photos', 'Unify with different person')" + :title="t('photos', 'Unify with different person')" + @click="showMergeModal = true"> + <template #icon> + <Merge /> + </template> + </ActionButton> <template v-if="selectedFileIds.length"> <ActionButton :close-after-click="true" :aria-label="t('photos', 'Download selected files')" @@ -135,6 +144,12 @@ </Button> </div> </Modal> + + <Modal v-if="showMergeModal" + :title="t('photos', 'Unify person')" + @close="showMergeModal = false"> + <FaceMergeForm :first-face="faceName" @select="handleMerge($event)" /> + </Modal> </div> </template> @@ -147,6 +162,7 @@ import AlertCircle from 'vue-material-design-icons/AlertCircle' import Star from 'vue-material-design-icons/Star' import DownloadOutline from 'vue-material-design-icons/DownloadOutline' import Send from 'vue-material-design-icons/Send' +import Merge from 'vue-material-design-icons/Merge' import { Actions, ActionButton, Modal, EmptyContent, Button } from '@nextcloud/vue' @@ -159,10 +175,12 @@ import FaceIllustration from '../assets/Illustrations/face.svg' import logger from '../services/logger.js' import FetchFacesMixin from '../mixins/FetchFacesMixin.js' import Vue from 'vue' +import FaceMergeForm from '../components/FaceMergeForm.vue' export default { name: 'FaceContent', components: { + FaceMergeForm, Pencil, Star, DownloadOutline, @@ -178,6 +196,7 @@ export default { Send, Button, CloseBoxMultiple, + Merge, }, directives: { @@ -201,8 +220,7 @@ export default { data() { return { - showAddPhotosModal: false, - showShareModal: false, + showMergeModal: false, showRenameModal: false, FaceIllustration, loadingCount: 0, @@ -252,6 +270,7 @@ export default { 'downloadFiles', 'toggleFavoriteForFiles', 'removeFilesFromFace', + 'moveFilesToFace', ]), openViewer(fileId) { @@ -303,6 +322,20 @@ export default { } }, + async handleMerge(faceName) { + try { + this.loadingCount++ + await this.moveFilesToFace({ oldFace: this.faceName, faceName, fileIdsToMove: this.facesFiles[this.faceName] }) + await this.deleteFace({ faceName: this.faceName }) + this.showMergeModal = false + this.$router.push({ name: 'facecontent', params: { faceName } }) + } catch (error) { + logger.error(error) + } finally { + this.loadingCount-- + } + }, + async favoriteSelection() { try { this.loadingCount++ |