1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 14:41:59 +00:00

feat(server): Merge Faces sorted by Similarity (#14635)

* Merge Faces sorted by Similarity

* Adds face sorting to the side panel face merger

* run make open-api

* Make it one query

* Only have the single order by when sorting by closest face
This commit is contained in:
Lukas 2024-12-16 09:47:11 -05:00 committed by GitHub
parent 8945a5d862
commit 12e55f5bf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 119 additions and 41 deletions

Binary file not shown.

View file

@ -3846,6 +3846,24 @@
"get": { "get": {
"operationId": "getAllPeople", "operationId": "getAllPeople",
"parameters": [ "parameters": [
{
"name": "closestAssetId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "closestPersonId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{ {
"name": "page", "name": "page",
"required": false, "required": false,

View file

@ -2362,7 +2362,9 @@ export function updatePartner({ id, updatePartnerDto }: {
body: updatePartnerDto body: updatePartnerDto
}))); })));
} }
export function getAllPeople({ page, size, withHidden }: { export function getAllPeople({ closestAssetId, closestPersonId, page, size, withHidden }: {
closestAssetId?: string;
closestPersonId?: string;
page?: number; page?: number;
size?: number; size?: number;
withHidden?: boolean; withHidden?: boolean;
@ -2371,6 +2373,8 @@ export function getAllPeople({ page, size, withHidden }: {
status: 200; status: 200;
data: PeopleResponseDto; data: PeopleResponseDto;
}>(`/people${QS.query(QS.explode({ }>(`/people${QS.query(QS.explode({
closestAssetId,
closestPersonId,
page, page,
size, size,
withHidden withHidden

View file

@ -31,8 +31,8 @@ export class PersonController {
@Get() @Get()
@Authenticated({ permission: Permission.PERSON_READ }) @Authenticated({ permission: Permission.PERSON_READ })
getAllPeople(@Auth() auth: AuthDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> { getAllPeople(@Auth() auth: AuthDto, @Query() options: PersonSearchDto): Promise<PeopleResponseDto> {
return this.service.getAll(auth, withHidden); return this.service.getAll(auth, options);
} }
@Post() @Post()

View file

@ -67,6 +67,10 @@ export class MergePersonDto {
export class PersonSearchDto { export class PersonSearchDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
withHidden?: boolean; withHidden?: boolean;
@ValidateUUID({ optional: true })
closestPersonId?: string;
@ValidateUUID({ optional: true })
closestAssetId?: string;
/** Page number for pagination */ /** Page number for pagination */
@ApiPropertyOptional() @ApiPropertyOptional()

View file

@ -10,6 +10,7 @@ export const IPersonRepository = 'IPersonRepository';
export interface PersonSearchOptions { export interface PersonSearchOptions {
minimumFaceCount: number; minimumFaceCount: number;
withHidden: boolean; withHidden: boolean;
closestFaceAssetId?: string;
} }
export interface PersonNameSearchOptions { export interface PersonNameSearchOptions {

View file

@ -83,7 +83,11 @@ export class PersonRepository implements IPersonRepository {
} }
@GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] }) @GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] })
getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions): Paginated<PersonEntity> { async getAllForUser(
pagination: PaginationOptions,
userId: string,
options?: PersonSearchOptions,
): Paginated<PersonEntity> {
const queryBuilder = this.personRepository const queryBuilder = this.personRepository
.createQueryBuilder('person') .createQueryBuilder('person')
.innerJoin('person.faces', 'face') .innerJoin('person.faces', 'face')
@ -97,10 +101,22 @@ export class PersonRepository implements IPersonRepository {
.addOrderBy('person.createdAt') .addOrderBy('person.createdAt')
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
.groupBy('person.id'); .groupBy('person.id');
if (options?.closestFaceAssetId) {
const innerQueryBuilder = this.faceSearchRepository
.createQueryBuilder('face_search')
.select('embedding', 'embedding')
.where('"face_search"."faceId" = "person"."faceAssetId"');
const faceSelectQueryBuilder = this.faceSearchRepository
.createQueryBuilder('face_search')
.select('embedding', 'embedding')
.where('"face_search"."faceId" = :faceId', { faceId: options.closestFaceAssetId });
queryBuilder
.orderBy('(' + innerQueryBuilder.getQuery() + ') <=> (' + faceSelectQueryBuilder.getQuery() + ')')
.setParameters(faceSelectQueryBuilder.getParameters());
}
if (!options?.withHidden) { if (!options?.withHidden) {
queryBuilder.andWhere('person.isHidden = false'); queryBuilder.andWhere('person.isHidden = false');
} }
return paginatedBuilder(queryBuilder, { return paginatedBuilder(queryBuilder, {
mode: PaginationMode.LIMIT_OFFSET, mode: PaginationMode.LIMIT_OFFSET,
...pagination, ...pagination,

View file

@ -55,16 +55,25 @@ import { IsNull } from 'typeorm';
@Injectable() @Injectable()
export class PersonService extends BaseService { export class PersonService extends BaseService {
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> { async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
const { withHidden = false, page, size } = dto; const { withHidden = false, closestAssetId, closestPersonId, page, size } = dto;
let closestFaceAssetId = closestAssetId;
const pagination = { const pagination = {
take: size, take: size,
skip: (page - 1) * size, skip: (page - 1) * size,
}; };
if (closestPersonId) {
const person = await this.personRepository.getById(closestPersonId);
if (!person?.faceAssetId) {
throw new NotFoundException('Person not found');
}
closestFaceAssetId = person.faceAssetId;
}
const { machineLearning } = await this.getConfig({ withCache: false }); const { machineLearning } = await this.getConfig({ withCache: false });
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
minimumFaceCount: machineLearning.facialRecognition.minFaces, minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden, withHidden,
closestFaceAssetId,
}); });
const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id); const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id);

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { getPersonNameWithHiddenValue } from '$lib/utils/person'; import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk'; import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto, getAllPeople } from '@immich/sdk';
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js'; import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { linear } from 'svelte/easing'; import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
@ -13,9 +13,10 @@
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { zoomImageToBase64 } from '$lib/utils/people-utils'; import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { handleError } from '$lib/utils/handle-error';
import { onMount } from 'svelte';
interface Props { interface Props {
allPeople: PersonResponseDto[];
editedFace: AssetFaceResponseDto; editedFace: AssetFaceResponseDto;
assetId: string; assetId: string;
assetType: AssetTypeEnum; assetType: AssetTypeEnum;
@ -24,7 +25,24 @@
onReassign: (person: PersonResponseDto) => void; onReassign: (person: PersonResponseDto) => void;
} }
let { allPeople, editedFace, assetId, assetType, onClose, onCreatePerson, onReassign }: Props = $props(); let { editedFace, assetId, assetType, onClose, onCreatePerson, onReassign }: Props = $props();
let allPeople: PersonResponseDto[] = $state([]);
let isShowLoadingPeople = $state(false);
async function loadPeople() {
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
try {
const { people } = await getAllPeople({ withHidden: true, closestAssetId: editedFace.id });
allPeople = people;
} catch (error) {
handleError(error, $t('errors.cant_get_faces'));
} finally {
clearTimeout(timeout);
}
isShowLoadingPeople = false;
}
// loading spinners // loading spinners
let isShowLoadingNewPerson = $state(false); let isShowLoadingNewPerson = $state(false);
@ -37,6 +55,10 @@
let showPeople = $derived(searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden)); let showPeople = $derived(searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden));
onMount(() => {
handlePromiseError(loadPeople());
});
const handleCreatePerson = async () => { const handleCreatePerson = async () => {
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner); const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
@ -96,31 +118,40 @@
</div> </div>
<div class="px-4 py-4 text-sm"> <div class="px-4 py-4 text-sm">
<h2 class="mb-8 mt-4 uppercase">{$t('all_people')}</h2> <h2 class="mb-8 mt-4 uppercase">{$t('all_people')}</h2>
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto"> {#if isShowLoadingPeople}
{#each showPeople as person (person.id)} <div class="flex w-full justify-center">
{#if !editedFace.person || person.id !== editedFace.person.id} <LoadingSpinner />
<div class="w-fit"> </div>
<button type="button" class="w-[90px]" onclick={() => onReassign(person)}> {:else}
<div class="relative"> <div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
<ImageThumbnail {#each showPeople as person (person.id)}
curve {#if !editedFace.person || person.id !== editedFace.person.id}
shadow <div class="w-fit">
url={getPeopleThumbnailUrl(person)} <button type="button" class="w-[90px]" onclick={() => onReassign(person)}>
altText={$getPersonNameWithHiddenValue(person.name, person.isHidden)} <div class="relative">
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)} <ImageThumbnail
widthStyle="90px" curve
heightStyle="90px" shadow
hidden={person.isHidden} url={getPeopleThumbnailUrl(person)}
/> altText={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
</div> title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
widthStyle="90px"
heightStyle="90px"
hidden={person.isHidden}
/>
</div>
<p class="mt-1 truncate font-medium" title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}> <p
{person.name} class="mt-1 truncate font-medium"
</p> title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
</button> >
</div> {person.name}
{/if} </p>
{/each} </button>
</div> </div>
{/if}
{/each}
</div>
{/if}
</div> </div>
</section> </section>

View file

@ -35,7 +35,7 @@
let peopleToNotShow = $derived([...selectedPeople, person]); let peopleToNotShow = $derived([...selectedPeople, person]);
onMount(async () => { onMount(async () => {
const data = await getAllPeople({ withHidden: false }); const data = await getAllPeople({ withHidden: false, closestPersonId: person.id });
people = data.people; people = data.people;
}); });

View file

@ -8,7 +8,6 @@
import { getPersonNameWithHiddenValue } from '$lib/utils/person'; import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import { import {
createPerson, createPerson,
getAllPeople,
getFaces, getFaces,
reassignFacesById, reassignFacesById,
AssetTypeEnum, AssetTypeEnum,
@ -53,7 +52,6 @@
// search people // search people
let showSelectedFaces = $state(false); let showSelectedFaces = $state(false);
let allPeople: PersonResponseDto[] = $state([]);
// timers // timers
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>; let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
@ -64,8 +62,6 @@
async function loadPeople() { async function loadPeople() {
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner); const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
try { try {
const { people } = await getAllPeople({ withHidden: true });
allPeople = people;
peopleWithFaces = await getFaces({ id: assetId }); peopleWithFaces = await getFaces({ id: assetId });
} catch (error) { } catch (error) {
handleError(error, $t('errors.cant_get_faces')); handleError(error, $t('errors.cant_get_faces'));
@ -322,7 +318,6 @@
{#if showSelectedFaces && editedFace} {#if showSelectedFaces && editedFace}
<AssignFaceSidePanel <AssignFaceSidePanel
{allPeople}
{editedFace} {editedFace}
{assetId} {assetId}
{assetType} {assetType}