Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/photos.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarcel Klehr <mklehr@gmx.net>2022-08-16 14:07:23 +0300
committerMarcel Klehr <mklehr@gmx.net>2022-08-23 15:30:18 +0300
commit0723f152afdbf3f24b07e97446fd10e3c31e604d (patch)
treef91c6b35e23466f0f3688c4604bbd76eafef205f
parentdf59eaa668028f3d5804666c93789e29ac36e28e (diff)
Implement merging faces
Signed-off-by: Marcel Klehr <mklehr@gmx.net>
-rw-r--r--src/components/FaceCover.vue29
-rw-r--r--src/components/FaceMergeForm.vue118
-rw-r--r--src/mixins/FaceCoverMixin.js72
-rw-r--r--src/store/faces.js32
-rw-r--r--src/views/FaceContent.vue37
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++