1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-27 10:58:13 +02:00

fix(server): "all" button for facial recognition deleting faces instead of unassigning them (#13042)

* unassign faces instead of deleting them

* formatting
This commit is contained in:
Mert 2024-09-30 00:29:14 -04:00 committed by GitHub
parent 9b309e84c9
commit 2f13db51df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 39 additions and 29 deletions

View File

@ -1,6 +1,7 @@
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { Paginated, PaginationOptions } from 'src/utils/pagination';
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
@ -40,10 +41,12 @@ export interface PeopleStatistics {
hidden: number;
}
export interface DeleteAllFacesOptions {
sourceType?: string;
export interface DeleteFacesOptions {
sourceType: SourceType;
}
export type UnassignFacesOptions = DeleteFacesOptions;
export interface IPersonRepository {
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
@ -59,7 +62,7 @@ export interface IPersonRepository {
createFaces(entities: Partial<AssetFaceEntity>[]): Promise<string[]>;
delete(entities: PersonEntity[]): Promise<void>;
deleteAll(): Promise<void>;
deleteAllFaces(options: DeleteAllFacesOptions): Promise<void>;
deleteFaces(options: DeleteFacesOptions): Promise<void>;
replaceFaces(assetId: string, entities: Partial<AssetFaceEntity>[], sourceType?: string): Promise<string[]>;
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>;
getFaceById(id: string): Promise<AssetFaceEntity>;
@ -75,6 +78,7 @@ export interface IPersonRepository {
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>;
unassignFaces(options: UnassignFacesOptions): Promise<void>;
update(person: Partial<PersonEntity>): Promise<PersonEntity>;
updateAll(people: Partial<PersonEntity>[]): Promise<void>;
getLatestFaceDate(): Promise<string | undefined>;

View File

@ -9,13 +9,14 @@ import { PersonEntity } from 'src/entities/person.entity';
import { PaginationMode, SourceType } from 'src/enum';
import {
AssetFaceId,
DeleteAllFacesOptions,
DeleteFacesOptions,
IPersonRepository,
PeopleStatistics,
PersonNameResponse,
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
UnassignFacesOptions,
UpdateFacesData,
} from 'src/interfaces/person.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@ -39,12 +40,23 @@ export class PersonRepository implements IPersonRepository {
.createQueryBuilder()
.update()
.set({ personId: newPersonId })
.where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined))
.where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined))
.execute();
return result.affected ?? 0;
}
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: null })
.where({ sourceType })
.execute();
await this.vacuum({ reindexVectors: false });
}
async delete(entities: PersonEntity[]): Promise<void> {
await this.personRepository.remove(entities);
}
@ -53,21 +65,14 @@ export class PersonRepository implements IPersonRepository {
await this.personRepository.clear();
}
async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise<void> {
if (!sourceType) {
return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
}
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
await this.assetFaceRepository
.createQueryBuilder('asset_faces')
.delete()
.andWhere('sourceType = :sourceType', { sourceType })
.execute();
await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search');
if (sourceType === SourceType.MACHINE_LEARNING) {
await this.assetFaceRepository.query('REINDEX INDEX face_index');
}
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
}
getAllFaces(
@ -331,4 +336,13 @@ export class PersonRepository implements IPersonRepository {
const { id } = await this.personRepository.save(person);
return this.personRepository.findOneByOrFail({ id });
}
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person');
await this.assetFaceRepository.query('REINDEX TABLE asset_faces');
await this.assetFaceRepository.query('REINDEX TABLE person');
if (reindexVectors) {
await this.assetFaceRepository.query('REINDEX TABLE face_search');
}
}
}

View File

@ -660,7 +660,7 @@ describe(PersonService.name, () => {
expect(systemMock.set).not.toHaveBeenCalled();
});
it('should delete existing people and faces if forced', async () => {
it('should delete existing people if forced', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAll.mockResolvedValue({
items: [faceStub.face1.person, personStub.randomPerson],
@ -675,7 +675,8 @@ describe(PersonService.name, () => {
await sut.handleQueueRecognizeFaces({ force: true });
expect(personMock.deleteAllFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
expect(personMock.deleteFaces).not.toHaveBeenCalled();
expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.FACIAL_RECOGNITION,

View File

@ -276,16 +276,6 @@ export class PersonService {
this.logger.debug(`Deleted ${people.length} people`);
}
private async deleteAllPeople() {
const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.repository.getAll({ ...pagination, skip: 0 }),
);
for await (const people of personPagination) {
await this.delete(people); // deletes thumbnails too
}
}
async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.repository.getAllWithoutFaces();
await this.delete(people);
@ -299,7 +289,7 @@ export class PersonService {
}
if (force) {
await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.repository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.handlePersonCleanup();
}
@ -407,7 +397,7 @@ export class PersonService {
const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION);
if (force) {
await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.repository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.handlePersonCleanup();
} else if (waiting) {
this.logger.debug(

View File

@ -18,7 +18,7 @@ export const newPersonRepositoryMock = (): Mocked<IPersonRepository> => {
updateAll: vitest.fn(),
delete: vitest.fn(),
deleteAll: vitest.fn(),
deleteAllFaces: vitest.fn(),
deleteFaces: vitest.fn(),
getStatistics: vitest.fn(),
getAllFaces: vitest.fn(),
@ -26,6 +26,7 @@ export const newPersonRepositoryMock = (): Mocked<IPersonRepository> => {
getRandomFace: vitest.fn(),
reassignFaces: vitest.fn(),
unassignFaces: vitest.fn(),
createFaces: vitest.fn(),
replaceFaces: vitest.fn(),
getFaces: vitest.fn(),