Implement unclustered photos for recognize (fix #475)

Signed-off-by: Varun Patil <radialapps@gmail.com>
pull/743/head
Varun Patil 2023-07-03 17:07:50 -07:00
parent a87c29a2fa
commit 590d8272d5
11 changed files with 125 additions and 82 deletions

View File

@ -94,7 +94,7 @@ class RecognizeBackend extends Backend
// Join with cluster // Join with cluster
$clusterQuery = null; $clusterQuery = null;
if ('NULL' === $faceName) { if ('NULL' === $faceName) {
$clusterQuery = $query->expr()->isNull('rfd.cluster_id'); $clusterQuery = $query->expr()->eq('rfd.cluster_id', $query->expr()->literal(-1));
} else { } else {
$nameField = is_numeric($faceName) ? 'rfc.id' : 'rfc.title'; $nameField = is_numeric($faceName) ? 'rfc.id' : 'rfc.title';
$query->innerJoin('m', 'recognize_face_clusters', 'rfc', $query->expr()->andX( $query->innerJoin('m', 'recognize_face_clusters', 'rfc', $query->expr()->andX(

View File

@ -201,18 +201,25 @@ export default defineComponent({
if: (self: any) => self.config.albums_enabled && !self.routeIsAlbums, if: (self: any) => self.config.albums_enabled && !self.routeIsAlbums,
}, },
{ {
id: 'face-move',
name: t('memories', 'Move to person'), name: t('memories', 'Move to person'),
icon: MoveIcon, icon: MoveIcon,
callback: this.moveSelectionToPerson.bind(this), callback: this.moveSelectionToPerson.bind(this),
if: () => this.$route.name === 'recognize', if: () => this.routeIsRecognize,
}, },
{ {
name: t('memories', 'Remove from person'), name: t('memories', 'Remove from person'),
icon: CloseIcon, icon: CloseIcon,
callback: this.removeSelectionFromPerson.bind(this), callback: this.removeSelectionFromPerson.bind(this),
if: () => this.$route.name === 'recognize', if: () => this.routeIsRecognize && !this.routeIsRecognizeUnassigned,
}, },
]; ];
// Move face-move to start if unassigned faces
if (this.routeIsRecognizeUnassigned) {
const i = this.defaultActions.findIndex((a) => a.id === 'face-move');
this.defaultActions.unshift(this.defaultActions.splice(i, 1)[0]);
}
}, },
beforeDestroy() { beforeDestroy() {
@ -800,7 +807,7 @@ export default defineComponent({
* Move selected photos to another person * Move selected photos to another person
*/ */
async moveSelectionToPerson(selection: Selection) { async moveSelectionToPerson(selection: Selection) {
if (!this.config.show_face_rect) { if (!this.config.show_face_rect && !this.routeIsRecognizeUnassigned) {
showError(this.t('memories', 'You must enable "Mark person in preview" to use this feature')); showError(this.t('memories', 'You must enable "Mark person in preview" to use this feature'));
return; return;
} }

View File

@ -593,7 +593,7 @@ export default defineComponent({
API.DAYS_FILTER(query, filter, `${user}/${name}`); API.DAYS_FILTER(query, filter, `${user}/${name}`);
// Face rect // Face rect
if (this.config.show_face_rect) { if (this.config.show_face_rect || this.routeIsRecognizeUnassigned) {
API.DAYS_FILTER(query, DaysFilterType.FACE_RECT); API.DAYS_FILTER(query, DaysFilterType.FACE_RECT);
} }
} }

View File

@ -90,16 +90,7 @@ export default defineComponent({
const name = this.$route.params.name || ''; const name = this.$route.params.name || '';
const target = String(face.name || face.cluster_id); const target = String(face.name || face.cluster_id);
if ( if (!confirm(this.t('memories', 'Move the selected photos to {target}?', { target }))) return;
!confirm(
this.t('memories', 'Are you sure you want to move the selected photos from {name} to {target}?', {
name,
target,
})
)
) {
return;
}
try { try {
this.show = false; this.show = false;

View File

@ -1,43 +1,54 @@
<template> <template>
<div v-if="name" class="face-top-matter"> <div class="face-top-matter">
<NcActions> <NcActions v-if="name">
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()"> <NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t('memories', 'Back') }} {{ t('memories', 'Back') }}
<template #icon> <BackIcon :size="20" /> </template> <template #icon> <BackIcon :size="20" /> </template>
</NcActionButton> </NcActionButton>
</NcActions> </NcActions>
<div class="name">{{ name }}</div> <div class="name" :class="{ rename: isReal }" @click="rename">{{ displayName }}</div>
<div class="right-actions"> <div class="right-actions">
<NcActions :inline="1"> <NcActions :inline="0">
<NcActionButton :aria-label="t('memories', 'Rename person')" @click="$refs.editModal?.open()" close-after-click> <!-- root view (not cluster or unassigned) -->
{{ t('memories', 'Rename person') }} <template v-if="!name && routeIsRecognize && !routeIsRecognizeUnassigned">
<template #icon> <EditIcon :size="20" /> </template> <NcActionButton :aria-label="t('memories', 'Unassigned faces')" @click="openUnassigned" close-after-click>
</NcActionButton> {{ t('memories', 'Unassigned faces') }}
<NcActionButton <template #icon> <UnassignedIcon :size="20" /> </template>
:aria-label="t('memories', 'Merge with different person')" </NcActionButton>
@click="$refs.mergeModal?.open()" </template>
close-after-click
> <!-- real cluster -->
{{ t('memories', 'Merge with different person') }} <template v-if="isReal">
<template #icon> <MergeIcon :size="20" /> </template> <NcActionButton :aria-label="t('memories', 'Rename person')" @click="rename" close-after-click>
</NcActionButton> {{ t('memories', 'Rename person') }}
<NcActionCheckbox <template #icon> <EditIcon :size="20" /> </template>
:aria-label="t('memories', 'Mark person in preview')" </NcActionButton>
:checked.sync="config.show_face_rect" <NcActionButton
@change="changeShowFaceRect" :aria-label="t('memories', 'Merge with different person')"
> @click="$refs.mergeModal?.open()"
{{ t('memories', 'Mark person in preview') }} close-after-click
</NcActionCheckbox> >
<NcActionButton {{ t('memories', 'Merge with different person') }}
:aria-label="t('memories', 'Remove person')" <template #icon> <MergeIcon :size="20" /> </template>
@click="$refs.deleteModal?.open()" </NcActionButton>
close-after-click <NcActionCheckbox
> :aria-label="t('memories', 'Mark person in preview')"
{{ t('memories', 'Remove person') }} :checked.sync="config.show_face_rect"
<template #icon> <DeleteIcon :size="20" /> </template> @change="changeShowFaceRect"
</NcActionButton> >
{{ t('memories', 'Mark person in preview') }}
</NcActionCheckbox>
<NcActionButton
:aria-label="t('memories', 'Remove person')"
@click="$refs.deleteModal?.open()"
close-after-click
>
{{ t('memories', 'Remove person') }}
<template #icon> <DeleteIcon :size="20" /> </template>
</NcActionButton>
</template>
</NcActions> </NcActions>
</div> </div>
@ -56,6 +67,7 @@ import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton';
import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox'; import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox';
import { emit } from '@nextcloud/event-bus'; import { emit } from '@nextcloud/event-bus';
import { getCurrentUser } from '@nextcloud/auth';
import FaceEditModal from '../modal/FaceEditModal.vue'; import FaceEditModal from '../modal/FaceEditModal.vue';
import FaceDeleteModal from '../modal/FaceDeleteModal.vue'; import FaceDeleteModal from '../modal/FaceDeleteModal.vue';
@ -64,6 +76,9 @@ import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
import EditIcon from 'vue-material-design-icons/Pencil.vue'; import EditIcon from 'vue-material-design-icons/Pencil.vue';
import DeleteIcon from 'vue-material-design-icons/Close.vue'; import DeleteIcon from 'vue-material-design-icons/Close.vue';
import MergeIcon from 'vue-material-design-icons/Merge.vue'; import MergeIcon from 'vue-material-design-icons/Merge.vue';
import UnassignedIcon from 'vue-material-design-icons/AccountQuestion.vue';
import * as utils from '../../services/Utils';
export default defineComponent({ export default defineComponent({
name: 'FaceTopMatter', name: 'FaceTopMatter',
@ -78,6 +93,7 @@ export default defineComponent({
EditIcon, EditIcon,
DeleteIcon, DeleteIcon,
MergeIcon, MergeIcon,
UnassignedIcon,
}, },
mixins: [UserConfig], mixins: [UserConfig],
@ -86,6 +102,19 @@ export default defineComponent({
name() { name() {
return this.$route.params.name || ''; return this.$route.params.name || '';
}, },
isReal() {
return this.name && this.name !== utils.constants.FACE_NULL;
},
displayName() {
if (this.routeIsRecognizeUnassigned) {
return this.t('memories', 'Unassigned faces');
} else if (!this.name) {
return this.t('memories', 'People');
}
return this.name;
},
}, },
methods: { methods: {
@ -93,6 +122,20 @@ export default defineComponent({
this.$router.go(-1); this.$router.go(-1);
}, },
rename() {
if (this.name) (<any>this.$refs.editModal)?.open();
},
openUnassigned() {
this.$router.push({
name: this.$route.name as string,
params: {
user: String(getCurrentUser()?.uid),
name: utils.constants.FACE_NULL,
},
});
},
changeShowFaceRect() { changeShowFaceRect() {
this.updateSetting('show_face_rect'); this.updateSetting('show_face_rect');
emit('memories:timeline:hard-refresh', {}); emit('memories:timeline:hard-refresh', {});
@ -100,3 +143,14 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style scoped lang="scss">
.face-top-matter {
.name.rename:hover {
cursor: text;
text-decoration: underline;
text-decoration-color: var(--color-placeholder-light);
text-underline-offset: 5px;
}
}
</style>

View File

@ -20,8 +20,6 @@ import ClusterTopMatter from './ClusterTopMatter.vue';
import FaceTopMatter from './FaceTopMatter.vue'; import FaceTopMatter from './FaceTopMatter.vue';
import AlbumTopMatter from './AlbumTopMatter.vue'; import AlbumTopMatter from './AlbumTopMatter.vue';
import { TopMatterType } from '../../types';
export default defineComponent({ export default defineComponent({
name: 'TopMatter', name: 'TopMatter',
components: { components: {
@ -44,32 +42,17 @@ export default defineComponent({
}, },
computed: { computed: {
type() { currentmatter() {
switch (this.$route.name) { switch (this.$route.name) {
case 'folders': case 'folders':
return TopMatterType.FOLDER; return FolderTopMatter;
case 'albums': case 'albums':
return TopMatterType.ALBUM; return AlbumTopMatter;
case 'tags': case 'tags':
case 'places': case 'places':
return TopMatterType.CLUSTER; return ClusterTopMatter;
case 'recognize': case 'recognize':
case 'facerecognition': case 'facerecognition':
return this.$route.params.name ? TopMatterType.FACE : TopMatterType.CLUSTER;
default:
return TopMatterType.NONE;
}
},
currentmatter() {
switch (this.type) {
case TopMatterType.FOLDER:
return FolderTopMatter;
case TopMatterType.ALBUM:
return AlbumTopMatter;
case TopMatterType.CLUSTER:
return ClusterTopMatter;
case TopMatterType.FACE:
return FaceTopMatter; return FaceTopMatter;
default: default:
return null; return null;

View File

@ -25,6 +25,12 @@ export default defineComponent({
routeIsPeople(): boolean { routeIsPeople(): boolean {
return ['recognize', 'facerecognition'].includes(<string>this.$route.name); return ['recognize', 'facerecognition'].includes(<string>this.$route.name);
}, },
routeIsRecognize(): boolean {
return this.$route.name === 'recognize';
},
routeIsRecognizeUnassigned(): boolean {
return this.routeIsRecognize && this.$route.params.name === constants.FACE_NULL;
},
routeIsArchive(): boolean { routeIsArchive(): boolean {
return this.$route.name === 'archive'; return this.$route.name === 'archive';
}, },

View File

@ -5,6 +5,7 @@ import { generateUrl } from '@nextcloud/router';
import { IFace, IPhoto } from '../../types'; import { IFace, IPhoto } from '../../types';
import { API } from '../API'; import { API } from '../API';
import client from '../DavClient'; import client from '../DavClient';
import * as utils from '../Utils';
import * as base from './base'; import * as base from './base';
export async function getFaceList(app: 'recognize' | 'facerecognition') { export async function getFaceList(app: 'recognize' | 'facerecognition') {
@ -83,10 +84,16 @@ export async function* recognizeMoveFaceImages(user: string, face: string, targe
// Remove each file // Remove each file
const calls = photos.map((p) => async () => { const calls = photos.map((p) => async () => {
try { try {
await client.moveFile( const dest = `/recognize/${user}/faces/${target}`;
`/recognize/${user}/faces/${face}/${p.faceid}-${p.basename}`, const name = `${p.faceid}-${p.basename}`;
`/recognize/${user}/faces/${target}/${p.faceid}-${p.basename}`
); // NULL source needs special handling
let source = `/recognize/${user}/faces/${face}`;
if (face === utils.constants.FACE_NULL) {
source = `/recognize/${user}/unassigned-faces`;
}
await client.moveFile(`${source}/${name}`, `${dest}/${name}`);
return p.faceid!; return p.faceid!;
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@ -11,6 +11,8 @@ export const constants = {
FLAG_LEAVING: 1 << 5, FLAG_LEAVING: 1 << 5,
FLAG_IS_LOCAL: 1 << 6, FLAG_IS_LOCAL: 1 << 6,
}, },
FACE_NULL: 'NULL',
}; };
/** /**

View File

@ -208,18 +208,9 @@ export type ITick = {
key?: number; key?: number;
}; };
export type TopMatter = {
type: TopMatterType;
};
export enum TopMatterType {
NONE = 0,
FOLDER = 1,
CLUSTER = 2,
FACE = 3,
ALBUM = 4,
}
export type ISelectionAction = { export type ISelectionAction = {
/** Identifier (optional) */
id?: string;
/** Display text */ /** Display text */
name: string; name: string;
/** Icon component */ /** Icon component */

View File

@ -15,6 +15,8 @@ declare module 'vue' {
routeIsFolders: boolean; routeIsFolders: boolean;
routeIsAlbums: boolean; routeIsAlbums: boolean;
routeIsPeople: boolean; routeIsPeople: boolean;
routeIsRecognize: boolean;
routeIsRecognizeUnassigned: boolean;
routeIsArchive: boolean; routeIsArchive: boolean;
routeIsPlaces: boolean; routeIsPlaces: boolean;
routeIsMap: boolean; routeIsMap: boolean;