feat: unassign faces

This commit is contained in:
martabal
2024-05-11 19:52:23 +02:00
parent 757840c2fd
commit 7e9dcaacff
45 changed files with 1394 additions and 303 deletions

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto';
@@ -26,4 +26,10 @@ export class FaceController {
): Promise<PersonResponseDto> {
return this.service.reassignFacesById(auth, id, dto);
}
@Delete(':id')
@Authenticated()
unassignFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetFaceResponseDto> {
return this.service.unassignFace(auth, id);
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
import { Body, Controller, Delete, Get, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
@@ -83,6 +83,11 @@ export class PersonController {
return this.service.getAssets(auth, id);
}
@Delete()
unassignFaces(@Auth() auth: AuthDto, @Body() dto: AssetFaceUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.unassignFaces(auth, dto);
}
@Put(':id/reassign')
@Authenticated()
reassignFaces(

View File

@@ -2,7 +2,12 @@ import { ApiProperty } from '@nestjs/swagger';
import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
import { PersonWithFacesResponseDto, mapFacesWithoutPerson, mapPerson } from 'src/dtos/person.dto';
import {
PeopleWithFacesResponseDto,
PersonWithFacesResponseDto,
mapFacesWithoutPerson,
mapPerson,
} from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
@@ -43,7 +48,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
tags?: TagResponseDto[];
people?: PersonWithFacesResponseDto[];
people?: PeopleWithFacesResponseDto;
/**base64 encoded sha1 hash */
checksum!: string;
stackParentId?: string | null;
@@ -58,7 +63,7 @@ export type AssetMapOptions = {
auth?: AuthDto;
};
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
const peopleWithFaces = (faces: AssetFaceEntity[]): PeopleWithFacesResponseDto => {
const result: PersonWithFacesResponseDto[] = [];
if (faces) {
for (const face of faces) {
@@ -73,7 +78,7 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[]
}
}
return result;
return { faces: result, numberOfFaces: faces.length };
};
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
@@ -117,7 +122,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
people: peopleWithFaces(entity.faces),
people: entity.faces ? peopleWithFaces(entity.faces) : undefined,
checksum: entity.checksum.toString('base64'),
stackParentId: withStack ? entity.stack?.primaryAssetId : undefined,
stack: withStack

View File

@@ -77,6 +77,12 @@ export class PersonWithFacesResponseDto extends PersonResponseDto {
faces!: AssetFaceWithoutPersonResponseDto[];
}
export class PeopleWithFacesResponseDto {
faces!: PersonWithFacesResponseDto[];
@ApiProperty({ type: 'integer' })
numberOfFaces!: number;
}
export class AssetFaceWithoutPersonResponseDto {
@ValidateUUID()
id!: string;

View File

@@ -37,6 +37,9 @@ export class AssetFaceEntity {
@Column({ default: 0, type: 'int' })
boundingBoxY2!: number;
@Column({ default: false })
isEdited!: boolean;
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
asset!: AssetEntity;

View File

@@ -23,7 +23,7 @@ export interface AssetFaceId {
export interface UpdateFacesData {
oldPersonId?: string;
faceIds?: string[];
newPersonId: string;
newPersonId: string | null;
}
export interface PersonStatistics {
@@ -60,7 +60,7 @@ export interface IPersonRepository {
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
getStatistics(personId: string): Promise<PersonStatistics>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
reassignFace(assetFaceId: string, newPersonId: string | null): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>;
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddEditedAssetFace1715357609038 implements MigrationInterface {
name = 'AddEditedAssetFace1715357609038';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "isEdited" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "isEdited"`);
}
}

View File

@@ -192,6 +192,7 @@ SELECT
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
"AssetEntity__AssetEntity_faces"."isEdited" AS "AssetEntity__AssetEntity_faces_isEdited",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",

View File

@@ -71,6 +71,7 @@ SELECT
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
@@ -103,6 +104,7 @@ FROM
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
@@ -138,6 +140,7 @@ FROM
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
@@ -193,9 +196,10 @@ LIMIT
-- PersonRepository.reassignFace
UPDATE "asset_faces"
SET
"personId" = $1
"personId" = $1,
"isEdited" = $2
WHERE
"id" = $2
"id" = $3
-- PersonRepository.getByName
SELECT
@@ -281,6 +285,7 @@ FROM
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
"AssetEntity__AssetEntity_faces"."isEdited" AS "AssetEntity__AssetEntity_faces_isEdited",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
@@ -373,6 +378,7 @@ SELECT
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
@@ -424,7 +430,8 @@ SELECT
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2"
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited"
FROM
"asset_faces" "AssetFaceEntity"
WHERE

View File

@@ -207,6 +207,7 @@ WITH
"faces"."boundingBoxY1" AS "boundingBoxY1",
"faces"."boundingBoxX2" AS "boundingBoxX2",
"faces"."boundingBoxY2" AS "boundingBoxY2",
"faces"."isEdited" AS "isEdited",
"faces"."embedding" <= > $1 AS "distance"
FROM
"asset_faces" "faces"

View File

@@ -148,7 +148,7 @@ export class PersonRepository implements IPersonRepository {
const result = await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: newPersonId })
.set({ personId: newPersonId, isEdited: true })
.where({ id: assetFaceId })
.execute();

View File

@@ -266,7 +266,7 @@ export class AssetService {
}
if (data.ownerId !== auth.user.id || auth.sharedLink) {
data.people = [];
delete data.people;
}
return data;

View File

@@ -437,6 +437,36 @@ describe(PersonService.name, () => {
});
});
describe('unassignFace', () => {
it('should unassign a face', async () => {
personMock.getFaceById.mockResolvedValueOnce(faceStub.face1);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
personMock.reassignFace.mockResolvedValue(1);
personMock.getRandomFace.mockResolvedValue(null);
personMock.getFaceById.mockResolvedValueOnce(faceStub.unassignedFace);
await expect(sut.unassignFace(authStub.admin, faceStub.face1.id)).resolves.toStrictEqual(
mapFaces(faceStub.unassignedFace, authStub.admin),
);
});
});
describe('unassignFaces', () => {
it('should unassign a face', async () => {
personMock.getFacesByIds.mockResolvedValueOnce([faceStub.face1]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
personMock.reassignFace.mockResolvedValue(1);
personMock.getRandomFace.mockResolvedValue(null);
personMock.getFaceById.mockResolvedValueOnce(faceStub.unassignedFace);
await expect(
sut.unassignFaces(authStub.admin, { data: [{ assetId: faceStub.face1.id, personId: 'person-1' }] }),
).resolves.toStrictEqual([{ id: 'assetFaceId1', success: true }]);
});
});
describe('handlePersonCleanup', () => {
it('should delete people without faces', async () => {
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
@@ -550,7 +580,10 @@ describe(PersonService.name, () => {
await sut.handleQueueRecognizeFaces({});
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { personId: IsNull() } });
expect(personMock.getAllFaces).toHaveBeenCalledWith(
{ skip: 0, take: 1000 },
{ where: { personId: IsNull(), isEdited: false } },
);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.FACIAL_RECOGNITION,

View File

@@ -101,6 +101,22 @@ export class PersonService {
};
}
async unassignFace(auth: AuthDto, id: string): Promise<AssetFaceResponseDto> {
let face = await this.repository.getFaceById(id);
await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id);
if (face.personId) {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, face.personId);
}
await this.repository.reassignFace(face.id, null);
if (face.person && face.person.faceAssetId === face.id) {
await this.createNewFeaturePhoto([face.person.id]);
}
face = await this.repository.getFaceById(id);
return mapFaces(face, auth);
}
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
const person = await this.findOrFail(personId);
@@ -130,6 +146,34 @@ export class PersonService {
return result;
}
async unassignFaces(auth: AuthDto, dto: AssetFaceUpdateDto): Promise<BulkIdResponseDto[]> {
const changeFeaturePhoto: string[] = [];
const results: BulkIdResponseDto[] = [];
for (const data of dto.data) {
const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
for (const face of faces) {
await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id);
if (face.personId) {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, face.personId);
}
await this.repository.reassignFace(face.id, null);
if (face.person && face.person.faceAssetId === face.id) {
changeFeaturePhoto.push(face.person.id);
}
results.push({ id: face.id, success: true });
}
}
if (changeFeaturePhoto.length > 0) {
// Remove duplicates
await this.createNewFeaturePhoto([...changeFeaturePhoto]);
}
return results;
}
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
@@ -386,7 +430,7 @@ export class PersonService {
}
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull() } }),
this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull(), isEdited: false } }),
);
for await (const page of facePagination) {

View File

@@ -18,6 +18,7 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId2',
@@ -32,6 +33,7 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId3',
@@ -46,6 +48,7 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
mergeFace2: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId4',
@@ -60,6 +63,7 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId5',
@@ -74,6 +78,7 @@ export const faceStub = {
boundingBoxY2: 505,
imageHeight: 1000,
imageWidth: 1000,
isEdited: false,
}),
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId6',
@@ -88,6 +93,7 @@ export const faceStub = {
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
isEdited: false,
}),
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId7',
@@ -102,6 +108,7 @@ export const faceStub = {
boundingBoxY2: 495,
imageHeight: 500,
imageWidth: 500,
isEdited: false,
}),
noPerson1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId8',
@@ -116,6 +123,7 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
noPerson2: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId9',
@@ -130,5 +138,21 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
unassignedFace: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId',
assetId: assetStub.image.id,
asset: assetStub.image,
personId: null,
person: null,
embedding: [1, 2, 3, 4],
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
};

View File

@@ -73,7 +73,7 @@ const assetResponse: AssetResponseDto = {
exifInfo: assetInfo,
livePhotoVideoId: null,
tags: [],
people: [],
people: undefined,
checksum: 'ZmlsZSBoYXNo',
isTrashed: false,
libraryId: 'library-id',