mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +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:
parent
8945a5d862
commit
12e55f5bf0
11 changed files with 119 additions and 41 deletions
BIN
mobile/openapi/lib/api/people_api.dart
generated
BIN
mobile/openapi/lib/api/people_api.dart
generated
Binary file not shown.
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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,6 +118,11 @@
|
||||||
</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>
|
||||||
|
{#if isShowLoadingPeople}
|
||||||
|
<div class="flex w-full justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
||||||
{#each showPeople as person (person.id)}
|
{#each showPeople as person (person.id)}
|
||||||
{#if !editedFace.person || person.id !== editedFace.person.id}
|
{#if !editedFace.person || person.id !== editedFace.person.id}
|
||||||
|
@ -114,7 +141,10 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-1 truncate font-medium" title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}>
|
<p
|
||||||
|
class="mt-1 truncate font-medium"
|
||||||
|
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||||
|
>
|
||||||
{person.name}
|
{person.name}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
@ -122,5 +152,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Loading…
Reference in a new issue