Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web/server): Face thumbnail selection #3081

Merged
merged 16 commits into from
Jul 2, 2023
1 change: 1 addition & 0 deletions mobile/openapi/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions mobile/openapi/doc/PersonApi.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions mobile/openapi/lib/api/person_api.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions mobile/openapi/test/person_api_test.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions server/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2730,6 +2730,48 @@
]
}
},
"/person/{id}/update-face/{assetId}": {
"put": {
"operationId": "updateFaceThumbnail",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "assetId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Person"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/search": {
"get": {
"operationId": "search",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ describe(FacialRecognitionService.name, () => {
personId: 'person-1',
assetId: 'asset-id',
embedding: [1, 2, 3, 4],
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
});
});

Expand All @@ -207,6 +213,12 @@ describe(FacialRecognitionService.name, () => {
personId: 'person-1',
assetId: 'asset-id',
embedding: [1, 2, 3, 4],
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
});
expect(jobMock.queue.mock.calls).toEqual([
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,16 @@ export class FacialRecognitionService {

const faceId: AssetFaceId = { assetId: asset.id, personId };

await this.faceRepository.create({ ...faceId, embedding });
await this.faceRepository.create({
...faceId,
embedding,
imageHeight: rest.imageHeight,
imageWidth: rest.imageWidth,
boundingBoxX1: rest.boundingBox.x1,
boundingBoxX2: rest.boundingBox.x2,
boundingBoxY1: rest.boundingBox.y1,
boundingBoxY2: rest.boundingBox.y2,
});
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
}

Expand Down
6 changes: 4 additions & 2 deletions server/src/domain/person/person.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AssetEntity, PersonEntity } from '@app/infra/entities';

import { AssetFaceId } from '@app/domain';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '@app/infra/entities';
export const IPersonRepository = 'IPersonRepository';

export interface PersonSearchOptions {
Expand All @@ -16,4 +16,6 @@ export interface IPersonRepository {
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
delete(entity: PersonEntity): Promise<PersonEntity | null>;
deleteAll(): Promise<number>;

getFaceByAssetId(payload: AssetFaceId): Promise<AssetFaceEntity>;
alextran1502 marked this conversation as resolved.
Show resolved Hide resolved
}
25 changes: 25 additions & 0 deletions server/src/domain/person/person.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ export class PersonService {
return mapPerson(person);
}

async updateFaceThumbnail(authUser: AuthUserDto, personId: string, assetId: string): Promise<void> {
const exists = await this.repository.getById(authUser.id, personId);
if (!exists) {
throw new BadRequestException();
}

const face = await this.repository.getFaceByAssetId({ assetId, personId });
alextran1502 marked this conversation as resolved.
Show resolved Hide resolved

return await this.jobRepository.queue({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
assetId: assetId,
personId,
boundingBox: {
x1: face.boundingBoxX1,
x2: face.boundingBoxX2,
y1: face.boundingBoxY1,
y2: face.boundingBoxY2,
},
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
},
});
}

async handlePersonCleanup() {
const people = await this.repository.getAllWithoutFaces();
for (const person of people) {
Expand Down
9 changes: 9 additions & 0 deletions server/src/immich/controllers/person.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export class PersonController {
return this.service.getById(authUser, id);
}

@Put(':id/update-face/:assetId')
updateFaceThumbnail(
@AuthUser() authUser: AuthUserDto,
@Param('id') id: string,
@Param('assetId') assetId: string,
): Promise<void> {
return this.service.updateFaceThumbnail(authUser, id, assetId);
}

@Put(':id')
updatePerson(
@AuthUser() authUser: AuthUserDto,
Expand Down
18 changes: 18 additions & 0 deletions server/src/infra/entities/asset-face.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ export class AssetFaceEntity {
})
embedding!: number[] | null;

@Column({ default: 0, type: 'int' })
imageWidth!: number;

@Column({ default: 0, type: 'int' })
imageHeight!: number;

@Column({ default: 0, type: 'int' })
boundingBoxX1!: number;

@Column({ default: 0, type: 'int' })
boundingBoxY1!: number;

@Column({ default: 0, type: 'int' })
boundingBoxX2!: number;

@Column({ default: 0, type: 'int' })
boundingBoxY2!: number;

@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
asset!: AssetEntity;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddDetectFaceResultInfo1688241394489 implements MigrationInterface {
name = 'AddDetectFaceResultInfo1688241394489';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "imageWidth" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "imageHeight" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxX1" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxY1" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxX2" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxY2" integer NOT NULL DEFAULT '0'`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxY2"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxX2"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxY1"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxX1"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "imageHeight"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "imageWidth"`);
}
}
6 changes: 5 additions & 1 deletion server/src/infra/repositories/person.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IPersonRepository, PersonSearchOptions } from '@app/domain';
import { AssetFaceId, IPersonRepository, PersonSearchOptions } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
Expand Down Expand Up @@ -76,4 +76,8 @@ export class PersonRepository implements IPersonRepository {
const { id } = await this.personRepository.save(entity);
return this.personRepository.findOneByOrFail({ id });
}

async getFaceByAssetId({ personId, assetId }: AssetFaceId): Promise<AssetFaceEntity> {
return this.assetFaceRepository.findOneByOrFail({ assetId, personId });
}
}
6 changes: 6 additions & 0 deletions server/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,12 @@ export const faceStub = {
personId: personStub.withName.id,
person: personStub.withName,
embedding: [1, 2, 3, 4],
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
}),
};

Expand Down
2 changes: 2 additions & 0 deletions server/test/repositories/person.repository.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
update: jest.fn(),
deleteAll: jest.fn(),
delete: jest.fn(),

getFaceByAssetId: jest.fn(),
};
};
Loading