mirror of
https://github.com/immich-app/immich.git
synced 2026-03-01 18:19:10 +03:00
feat(server): Import face regions from metadata (#6455)
* feat: faces-from-metadata - Import face regions from metadata Implements immich-app#1692. - OpenAPI spec changes to accomodate metadata face import configs. New settings to enable the feature. - Updates admin UI compoments - ML faces detection/recognition & Exif/Metadata faces compatibility Signed-off-by: BugFest <bugfest.dev@pm.me> * chore(web): remove unused file confirm-enable-import-faces * chore(web): format metadata-settings * fix(server): faces-from-metadata tests and format * fix(server): code refinements, nullable face asset sourceType * fix(server): Add RegionInfo to ImmichTags interface * fix(server): deleteAllFaces sourceType param can be undefined * fix(server): exiftool-vendored 27.0.0 moves readArgs into ExifToolOptions * fix(server): rename isImportFacesFromMetadataEnabled to isFaceImportEnabled * fix(server): simplify sourceType conditional * fix(server): small fixes * fix(server): handling sourceType * fix(server): sourceType enum * fix(server): refactor metadata applyTaggedFaces * fix(server): create/update signature changes * fix(server): reduce computational cost of Person.getManyByName * fix(server): use faceList instead of faceSet * fix(server): Skip regions without Name defined * fix(mobile): Update open-api (face assets feature changes) * fix(server): Face-Person reconciliation with map/index * fix(server): tags.RegionInfo.AppliedToDimensions must be defined to process face-region * fix(server): fix shared-link.service.ts format * fix(mobile): Update open-api after branch update * simplify * fix(server): minor fixes * fix(server): person create/update methods type enforcement * fix(server): style fixes * fix(server): remove unused metadata code * fix(server): metadata faces unit tests * fix(server): top level config metadata category * fix(server): rename upsertFaces to replaceFaces * fix(server): remove sourceType when unnecessary * fix(server): sourceType as ENUM * fix(server): format fixes * fix(server): fix tests after sourceType ENUM change * fix(server): remove unnecessary JobItem cast * fix(server): fix asset enum imports * fix(open-api): add metadata config * fix(mobile): update open-api after metadata open-api spec changes * fix(web): update web/api metadata config * fix(server): remove duplicated sourceType def * fix(server): update generated sql queries * fix(e2e): tests for metadata face import feature * fix(web): Fix check:typescript * fix(e2e): update subproject ref * fix(server): revert format changes to pass format checks after ci * fix(mobile): update open-api * fix(server,movile,open-api,mobile): sourceType as DB data type * fix(e2e): upload face asset after enabling metadata face import * fix(web): simplify metadata admin settings and i18n keys * Update person.repository.ts Co-authored-by: Jason Rasmussen <jason@rasm.me> * fix(server): asset_faces.sourceType column not nullable * fix(server): simplified syntax * fix(e2e): use SDK for everything except the endpoint being tested * fix(e2e): fix test format * chore: clean up * chore: clean up * chore: update e2e/test-assets --------- Signed-off-by: BugFest <bugfest.dev@pm.me> Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
@@ -25,7 +25,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { PersonPathType } from 'src/entities/move.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { AssetType, Permission, SystemMetadataKey } from 'src/enum';
|
||||
import { AssetType, Permission, SourceType, SystemMetadataKey } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
@@ -53,7 +53,7 @@ import { checkAccess, requireAccess } from 'src/utils/access';
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
@@ -173,10 +173,7 @@ export class PersonService {
|
||||
const assetFace = await this.repository.getRandomFace(personId);
|
||||
|
||||
if (assetFace !== null) {
|
||||
await this.repository.update({
|
||||
id: personId,
|
||||
faceAssetId: assetFace.id,
|
||||
});
|
||||
await this.repository.update([{ id: personId, faceAssetId: assetFace.id }]);
|
||||
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } });
|
||||
}
|
||||
}
|
||||
@@ -214,13 +211,16 @@ export class PersonService {
|
||||
return assets.map((asset) => mapAsset(asset));
|
||||
}
|
||||
|
||||
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||
return this.repository.create({
|
||||
ownerId: auth.user.id,
|
||||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
isHidden: dto.isHidden,
|
||||
});
|
||||
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||
const [created] = await this.repository.create([
|
||||
{
|
||||
ownerId: auth.user.id,
|
||||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
isHidden: dto.isHidden,
|
||||
},
|
||||
]);
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
@@ -239,7 +239,7 @@ export class PersonService {
|
||||
faceId = face.id;
|
||||
}
|
||||
|
||||
const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
|
||||
const [person] = await this.repository.update([{ id, faceAssetId: faceId, name, birthDate, isHidden }]);
|
||||
|
||||
if (assetId) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
||||
@@ -296,8 +296,8 @@ export class PersonService {
|
||||
}
|
||||
|
||||
if (force) {
|
||||
await this.deleteAllPeople();
|
||||
await this.repository.deleteAllFaces();
|
||||
await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING });
|
||||
await this.handlePersonCleanup();
|
||||
}
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
@@ -339,11 +339,7 @@ export class PersonService {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
if (!asset.isVisible) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
if (!asset.isVisible) {
|
||||
if (!asset.isVisible || asset.faces.length > 0) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
@@ -408,7 +404,8 @@ export class PersonService {
|
||||
const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION);
|
||||
|
||||
if (force) {
|
||||
await this.deleteAllPeople();
|
||||
await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING });
|
||||
await this.handlePersonCleanup();
|
||||
} else if (waiting) {
|
||||
this.logger.debug(
|
||||
`Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`,
|
||||
@@ -418,7 +415,9 @@ export class PersonService {
|
||||
|
||||
const lastRun = new Date().toISOString();
|
||||
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(), sourceType: IsNull() },
|
||||
}),
|
||||
);
|
||||
|
||||
for await (const page of facePagination) {
|
||||
@@ -441,13 +440,18 @@ export class PersonService {
|
||||
const face = await this.repository.getFaceByIdWithAssets(
|
||||
id,
|
||||
{ person: true, asset: true, faceSearch: true },
|
||||
{ id: true, personId: true, faceSearch: { embedding: true } },
|
||||
{ id: true, personId: true, sourceType: true, faceSearch: { embedding: true } },
|
||||
);
|
||||
if (!face || !face.asset) {
|
||||
this.logger.warn(`Face ${id} not found`);
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
if (face.sourceType !== SourceType.MACHINE_LEARNING) {
|
||||
this.logger.warn(`Skipping face ${id} due to source ${face.sourceType}`);
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
if (!face.faceSearch?.embedding) {
|
||||
this.logger.warn(`Face ${id} does not have an embedding`);
|
||||
return JobStatus.FAILED;
|
||||
@@ -497,7 +501,7 @@ export class PersonService {
|
||||
|
||||
if (isCore && !personId) {
|
||||
this.logger.log(`Creating new person for face ${id}`);
|
||||
const newPerson = await this.repository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
|
||||
const [newPerson] = await this.repository.create([{ ownerId: face.asset.ownerId, faceAssetId: face.id }]);
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
|
||||
personId = newPerson.id;
|
||||
}
|
||||
@@ -522,8 +526,8 @@ export class PersonService {
|
||||
}
|
||||
|
||||
async handleGeneratePersonThumbnail(data: IEntityJob): Promise<JobStatus> {
|
||||
const { machineLearning, image } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
const { machineLearning, metadata, image } = await this.configCore.getConfig({ withCache: true });
|
||||
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
@@ -573,7 +577,7 @@ export class PersonService {
|
||||
} as const;
|
||||
|
||||
await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions);
|
||||
await this.repository.update({ id: person.id, thumbnailPath });
|
||||
await this.repository.update([{ id: person.id, thumbnailPath }]);
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
@@ -620,7 +624,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
primaryPerson = await this.repository.update({ id: primaryPerson.id, ...update });
|
||||
[primaryPerson] = await this.repository.update([{ id: primaryPerson.id, ...update }]);
|
||||
}
|
||||
|
||||
const mergeName = mergePerson.name || mergePerson.id;
|
||||
|
||||
Reference in New Issue
Block a user