mirror of
https://github.com/immich-app/immich.git
synced 2025-06-15 03:24:35 -04:00
* feat(server): Avoid face matching with people born after file creation date (#4743) * lint * add medium tests for facial recognition --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
d11fb479d1
commit
555fa0ef8d
@ -136,6 +136,7 @@ with
|
|||||||
"asset_faces"
|
"asset_faces"
|
||||||
inner join "assets" on "assets"."id" = "asset_faces"."assetId"
|
inner join "assets" on "assets"."id" = "asset_faces"."assetId"
|
||||||
inner join "face_search" on "face_search"."faceId" = "asset_faces"."id"
|
inner join "face_search" on "face_search"."faceId" = "asset_faces"."id"
|
||||||
|
left join "person" on "person"."id" = "asset_faces"."personId"
|
||||||
where
|
where
|
||||||
"assets"."ownerId" = any ($2::uuid[])
|
"assets"."ownerId" = any ($2::uuid[])
|
||||||
and "assets"."deletedAt" is null
|
and "assets"."deletedAt" is null
|
||||||
|
@ -163,6 +163,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
|||||||
hasPerson?: boolean;
|
hasPerson?: boolean;
|
||||||
numResults: number;
|
numResults: number;
|
||||||
maxDistance: number;
|
maxDistance: number;
|
||||||
|
minBirthDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetDuplicateSearch {
|
export interface AssetDuplicateSearch {
|
||||||
@ -338,7 +339,7 @@ export class SearchRepository {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson }: FaceEmbeddingSearch) {
|
searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
|
||||||
if (!isValidInteger(numResults, { min: 1, max: 1000 })) {
|
if (!isValidInteger(numResults, { min: 1, max: 1000 })) {
|
||||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||||
}
|
}
|
||||||
@ -354,9 +355,13 @@ export class SearchRepository {
|
|||||||
])
|
])
|
||||||
.innerJoin('assets', 'assets.id', 'asset_faces.assetId')
|
.innerJoin('assets', 'assets.id', 'asset_faces.assetId')
|
||||||
.innerJoin('face_search', 'face_search.faceId', 'asset_faces.id')
|
.innerJoin('face_search', 'face_search.faceId', 'asset_faces.id')
|
||||||
|
.leftJoin('person', 'person.id', 'asset_faces.personId')
|
||||||
.where('assets.ownerId', '=', anyUuid(userIds))
|
.where('assets.ownerId', '=', anyUuid(userIds))
|
||||||
.where('assets.deletedAt', 'is', null)
|
.where('assets.deletedAt', 'is', null)
|
||||||
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
|
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
|
||||||
|
.$if(!!minBirthDate, (qb) =>
|
||||||
|
qb.where((eb) => eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)])),
|
||||||
|
)
|
||||||
.orderBy(sql`face_search.embedding <=> ${embedding}`)
|
.orderBy(sql`face_search.embedding <=> ${embedding}`)
|
||||||
.limit(numResults),
|
.limit(numResults),
|
||||||
)
|
)
|
||||||
|
@ -896,6 +896,66 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should match existing person if their birth date is unknown', async () => {
|
||||||
|
if (!faceStub.primaryFace1.person) {
|
||||||
|
throw new Error('faceStub.primaryFace1.person is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
const faces = [
|
||||||
|
{ ...faceStub.noPerson1, distance: 0 },
|
||||||
|
{ ...faceStub.primaryFace1, distance: 0.2 },
|
||||||
|
{ ...faceStub.withBirthDate, distance: 0.3 },
|
||||||
|
] as FaceSearchResult[];
|
||||||
|
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
|
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
|
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||||
|
|
||||||
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
|
|
||||||
|
expect(mocks.person.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
|
faceIds: expect.arrayContaining([faceStub.noPerson1.id]),
|
||||||
|
newPersonId: faceStub.primaryFace1.person.id,
|
||||||
|
});
|
||||||
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
|
faceIds: expect.not.arrayContaining([faceStub.face1.id]),
|
||||||
|
newPersonId: faceStub.primaryFace1.person.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match existing person if their birth date is before file creation', async () => {
|
||||||
|
if (!faceStub.primaryFace1.person) {
|
||||||
|
throw new Error('faceStub.primaryFace1.person is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
const faces = [
|
||||||
|
{ ...faceStub.noPerson1, distance: 0 },
|
||||||
|
{ ...faceStub.withBirthDate, distance: 0.2 },
|
||||||
|
{ ...faceStub.primaryFace1, distance: 0.3 },
|
||||||
|
] as FaceSearchResult[];
|
||||||
|
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
|
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
|
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||||
|
|
||||||
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
|
|
||||||
|
expect(mocks.person.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
|
faceIds: expect.arrayContaining([faceStub.noPerson1.id]),
|
||||||
|
newPersonId: faceStub.withBirthDate.person?.id,
|
||||||
|
});
|
||||||
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
|
faceIds: expect.not.arrayContaining([faceStub.face1.id]),
|
||||||
|
newPersonId: faceStub.withBirthDate.person?.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should create a new person if the face is a core point with no person', async () => {
|
it('should create a new person if the face is a core point with no person', async () => {
|
||||||
const faces = [
|
const faces = [
|
||||||
{ ...faceStub.noPerson1, distance: 0 },
|
{ ...faceStub.noPerson1, distance: 0 },
|
||||||
|
@ -483,6 +483,7 @@ export class PersonService extends BaseService {
|
|||||||
embedding: face.faceSearch.embedding,
|
embedding: face.faceSearch.embedding,
|
||||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||||
numResults: machineLearning.facialRecognition.minFaces,
|
numResults: machineLearning.facialRecognition.minFaces,
|
||||||
|
minBirthDate: face.asset.fileCreatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
// `matches` also includes the face itself
|
// `matches` also includes the face itself
|
||||||
@ -508,6 +509,7 @@ export class PersonService extends BaseService {
|
|||||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||||
numResults: 1,
|
numResults: 1,
|
||||||
hasPerson: true,
|
hasPerson: true,
|
||||||
|
minBirthDate: face.asset.fileCreatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matchWithPerson.length > 0) {
|
if (matchWithPerson.length > 0) {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { Insertable, Kysely } from 'kysely';
|
import { Insertable, Kysely } from 'kysely';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
import { Assets, DB, Partners, Sessions } from 'src/db';
|
import { AssetFaces, Assets, DB, Person as DbPerson, FaceSearch, Partners, Sessions } from 'src/db';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetType } from 'src/enum';
|
import { AssetType, SourceType } from 'src/enum';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||||
@ -37,7 +37,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos
|
|||||||
import { ViewRepository } from 'src/repositories/view-repository';
|
import { ViewRepository } from 'src/repositories/view-repository';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||||
import { newUuid } from 'test/small.factory';
|
import { newDate, newEmbedding, newUuid } from 'test/small.factory';
|
||||||
import { automock } from 'test/utils';
|
import { automock } from 'test/utils';
|
||||||
|
|
||||||
class CustomWritable extends Writable {
|
class CustomWritable extends Writable {
|
||||||
@ -61,12 +61,18 @@ type Asset = Partial<Insertable<Assets>>;
|
|||||||
type User = Partial<Insertable<UserTable>>;
|
type User = Partial<Insertable<UserTable>>;
|
||||||
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
|
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
|
||||||
type Partner = Insertable<Partners>;
|
type Partner = Insertable<Partners>;
|
||||||
|
type AssetFace = Partial<Insertable<AssetFaces>>;
|
||||||
|
type Person = Partial<Insertable<DbPerson>>;
|
||||||
|
type Face = Partial<Insertable<FaceSearch>>;
|
||||||
|
|
||||||
export class TestFactory {
|
export class TestFactory {
|
||||||
private assets: Asset[] = [];
|
private assets: Asset[] = [];
|
||||||
private sessions: Session[] = [];
|
private sessions: Session[] = [];
|
||||||
private users: User[] = [];
|
private users: User[] = [];
|
||||||
private partners: Partner[] = [];
|
private partners: Partner[] = [];
|
||||||
|
private assetFaces: AssetFace[] = [];
|
||||||
|
private persons: Person[] = [];
|
||||||
|
private faces: Face[] = [];
|
||||||
|
|
||||||
private constructor(private context: TestContext) {}
|
private constructor(private context: TestContext) {}
|
||||||
|
|
||||||
@ -141,6 +147,53 @@ export class TestFactory {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static assetFace(assetFace: AssetFace) {
|
||||||
|
const defaults = {
|
||||||
|
assetId: assetFace.assetId || newUuid(),
|
||||||
|
boundingBoxX1: assetFace.boundingBoxX1 || 0,
|
||||||
|
boundingBoxX2: assetFace.boundingBoxX2 || 1,
|
||||||
|
boundingBoxY1: assetFace.boundingBoxY1 || 0,
|
||||||
|
boundingBoxY2: assetFace.boundingBoxY2 || 1,
|
||||||
|
deletedAt: assetFace.deletedAt || null,
|
||||||
|
id: assetFace.id || newUuid(),
|
||||||
|
imageHeight: assetFace.imageHeight || 10,
|
||||||
|
imageWidth: assetFace.imageWidth || 10,
|
||||||
|
personId: assetFace.personId || null,
|
||||||
|
sourceType: assetFace.sourceType || SourceType.MACHINE_LEARNING,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...defaults, ...assetFace };
|
||||||
|
}
|
||||||
|
|
||||||
|
static person(person: Person) {
|
||||||
|
const defaults = {
|
||||||
|
birthDate: person.birthDate || null,
|
||||||
|
color: person.color || null,
|
||||||
|
createdAt: person.createdAt || newDate(),
|
||||||
|
faceAssetId: person.faceAssetId || null,
|
||||||
|
id: person.id || newUuid(),
|
||||||
|
isFavorite: person.isFavorite || false,
|
||||||
|
isHidden: person.isHidden || false,
|
||||||
|
name: person.name || 'Test Name',
|
||||||
|
ownerId: person.ownerId || newUuid(),
|
||||||
|
thumbnailPath: person.thumbnailPath || '/path/to/thumbnail.jpg',
|
||||||
|
updatedAt: person.updatedAt || newDate(),
|
||||||
|
updateId: person.updateId || newUuid(),
|
||||||
|
};
|
||||||
|
return { ...defaults, ...person };
|
||||||
|
}
|
||||||
|
|
||||||
|
static face(face: Face) {
|
||||||
|
const defaults = {
|
||||||
|
faceId: face.faceId || newUuid(),
|
||||||
|
embedding: face.embedding || newEmbedding(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...face,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
withAsset(asset: Asset) {
|
withAsset(asset: Asset) {
|
||||||
this.assets.push(asset);
|
this.assets.push(asset);
|
||||||
return this;
|
return this;
|
||||||
@ -161,6 +214,21 @@ export class TestFactory {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withAssetFace(assetFace: AssetFace) {
|
||||||
|
this.assetFaces.push(assetFace);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withPerson(person: Person) {
|
||||||
|
this.persons.push(person);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withFaces(face: Face) {
|
||||||
|
this.faces.push(face);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
async create() {
|
async create() {
|
||||||
for (const user of this.users) {
|
for (const user of this.users) {
|
||||||
await this.context.createUser(user);
|
await this.context.createUser(user);
|
||||||
@ -178,6 +246,16 @@ export class TestFactory {
|
|||||||
await this.context.createAsset(asset);
|
await this.context.createAsset(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const person of this.persons) {
|
||||||
|
await this.context.createPerson(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.context.refreshFaces(
|
||||||
|
this.assetFaces,
|
||||||
|
[],
|
||||||
|
this.faces.map((f) => TestFactory.face(f)),
|
||||||
|
);
|
||||||
|
|
||||||
return this.context;
|
return this.context;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -276,4 +354,16 @@ export class TestContext {
|
|||||||
createSession(session: Session) {
|
createSession(session: Session) {
|
||||||
return this.session.create(TestFactory.session(session));
|
return this.session.create(TestFactory.session(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createPerson(person: Person) {
|
||||||
|
return this.person.create(TestFactory.person(person));
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshFaces(facesToAdd: AssetFace[], faceIdsToRemove: string[], embeddingsToAdd?: Insertable<FaceSearch>[]) {
|
||||||
|
return this.person.refreshFaces(
|
||||||
|
facesToAdd.map((f) => TestFactory.assetFace(f)),
|
||||||
|
faceIdsToRemove,
|
||||||
|
embeddingsToAdd,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
15
server/test/fixtures/face.stub.ts
vendored
15
server/test/fixtures/face.stub.ts
vendored
@ -164,4 +164,19 @@ export const faceStub = {
|
|||||||
sourceType: SourceType.EXIF,
|
sourceType: SourceType.EXIF,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
|
withBirthDate: Object.freeze<AssetFaceEntity>({
|
||||||
|
id: 'assetFaceId10',
|
||||||
|
assetId: assetStub.image.id,
|
||||||
|
asset: assetStub.image,
|
||||||
|
personId: personStub.withBirthDate.id,
|
||||||
|
person: personStub.withBirthDate,
|
||||||
|
boundingBoxX1: 0,
|
||||||
|
boundingBoxY1: 0,
|
||||||
|
boundingBoxX2: 1,
|
||||||
|
boundingBoxY2: 1,
|
||||||
|
imageHeight: 1024,
|
||||||
|
imageWidth: 1024,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
|
deletedAt: null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
201
server/test/medium/specs/person.service.spec.ts
Normal file
201
server/test/medium/specs/person.service.spec.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { JobStatus, SourceType } from 'src/enum';
|
||||||
|
import { PersonService } from 'src/services/person.service';
|
||||||
|
import { TestContext, TestFactory } from 'test/factory';
|
||||||
|
import { newEmbedding } from 'test/small.factory';
|
||||||
|
import { getKyselyDB, newTestService } from 'test/utils';
|
||||||
|
|
||||||
|
const setup = async (db: Kysely<any>) => {
|
||||||
|
const context = await TestContext.from(db).create();
|
||||||
|
const { sut, mocks } = newTestService(PersonService, context);
|
||||||
|
|
||||||
|
return { sut, mocks, context };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.concurrent(PersonService.name, () => {
|
||||||
|
let sut: PersonService;
|
||||||
|
let context: TestContext;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ sut, context } = await setup(await getKyselyDB()));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleRecognizeFaces', () => {
|
||||||
|
it('should skip if face source type is not MACHINE_LEARNING', async () => {
|
||||||
|
const user = TestFactory.user();
|
||||||
|
const asset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const assetFace = TestFactory.assetFace({ assetId: asset.id, sourceType: SourceType.MANUAL });
|
||||||
|
const face = TestFactory.face({ faceId: assetFace.id });
|
||||||
|
await context.getFactory().withUser(user).withAsset(asset).withAssetFace(assetFace).withFaces(face).create();
|
||||||
|
|
||||||
|
const result = await sut.handleRecognizeFaces({ id: assetFace.id, deferred: false });
|
||||||
|
|
||||||
|
expect(result).toBe(JobStatus.SKIPPED);
|
||||||
|
const newPersonId = await context.db
|
||||||
|
.selectFrom('asset_faces')
|
||||||
|
.select('asset_faces.personId')
|
||||||
|
.where('asset_faces.id', '=', assetFace.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
expect(newPersonId?.personId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if face does not have an embedding', async () => {
|
||||||
|
const user = TestFactory.user();
|
||||||
|
const asset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const assetFace = TestFactory.assetFace({ assetId: asset.id, sourceType: SourceType.MACHINE_LEARNING });
|
||||||
|
await context.getFactory().withUser(user).withAsset(asset).withAssetFace(assetFace).create();
|
||||||
|
|
||||||
|
const result = await sut.handleRecognizeFaces({ id: assetFace.id, deferred: false });
|
||||||
|
|
||||||
|
expect(result).toBe(JobStatus.FAILED);
|
||||||
|
const newPersonId = await context.db
|
||||||
|
.selectFrom('asset_faces')
|
||||||
|
.select('asset_faces.personId')
|
||||||
|
.where('asset_faces.id', '=', assetFace.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
expect(newPersonId?.personId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip if face already has a person assigned', async () => {
|
||||||
|
const user = TestFactory.user();
|
||||||
|
const asset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const person = TestFactory.person({ ownerId: user.id });
|
||||||
|
const assetFace = TestFactory.assetFace({
|
||||||
|
assetId: asset.id,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
|
personId: person.id,
|
||||||
|
});
|
||||||
|
const face = TestFactory.face({ faceId: assetFace.id });
|
||||||
|
await context
|
||||||
|
.getFactory()
|
||||||
|
.withUser(user)
|
||||||
|
.withAsset(asset)
|
||||||
|
.withPerson(person)
|
||||||
|
.withAssetFace(assetFace)
|
||||||
|
.withFaces(face)
|
||||||
|
.create();
|
||||||
|
|
||||||
|
const result = await sut.handleRecognizeFaces({ id: assetFace.id, deferred: false });
|
||||||
|
|
||||||
|
expect(result).toBe(JobStatus.SKIPPED);
|
||||||
|
const newPersonId = await context.db
|
||||||
|
.selectFrom('asset_faces')
|
||||||
|
.select('asset_faces.personId')
|
||||||
|
.where('asset_faces.id', '=', assetFace.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
expect(newPersonId?.personId).toEqual(person.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new person if no matches are found', async () => {
|
||||||
|
const user = TestFactory.user();
|
||||||
|
const embedding = newEmbedding();
|
||||||
|
|
||||||
|
let factory = context.getFactory().withUser(user);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const existingAsset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const existingAssetFace = TestFactory.assetFace({
|
||||||
|
assetId: existingAsset.id,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
|
});
|
||||||
|
const existingFace = TestFactory.face({ faceId: existingAssetFace.id, embedding });
|
||||||
|
factory = factory.withAsset(existingAsset).withAssetFace(existingAssetFace).withFaces(existingFace);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAsset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const newAssetFace = TestFactory.assetFace({ assetId: newAsset.id, sourceType: SourceType.MACHINE_LEARNING });
|
||||||
|
const newFace = TestFactory.face({ faceId: newAssetFace.id, embedding });
|
||||||
|
|
||||||
|
await factory.withAsset(newAsset).withAssetFace(newAssetFace).withFaces(newFace).create();
|
||||||
|
|
||||||
|
const result = await sut.handleRecognizeFaces({ id: newAssetFace.id, deferred: false });
|
||||||
|
|
||||||
|
expect(result).toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
|
const newPersonId = await context.db
|
||||||
|
.selectFrom('asset_faces')
|
||||||
|
.select('asset_faces.personId')
|
||||||
|
.where('asset_faces.id', '=', newAssetFace.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(newPersonId.personId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should assign face to an existing person if matches are found', async () => {
|
||||||
|
const user = TestFactory.user();
|
||||||
|
const existingPerson = TestFactory.person({ ownerId: user.id });
|
||||||
|
const embedding = newEmbedding();
|
||||||
|
|
||||||
|
let factory = context.getFactory().withUser(user).withPerson(existingPerson);
|
||||||
|
|
||||||
|
const assetFaces: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const existingAsset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const existingAssetFace = TestFactory.assetFace({
|
||||||
|
assetId: existingAsset.id,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
|
});
|
||||||
|
assetFaces.push(existingAssetFace.id);
|
||||||
|
const existingFace = TestFactory.face({ faceId: existingAssetFace.id, embedding });
|
||||||
|
factory = factory.withAsset(existingAsset).withAssetFace(existingAssetFace).withFaces(existingFace);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAsset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const newAssetFace = TestFactory.assetFace({ assetId: newAsset.id, sourceType: SourceType.MACHINE_LEARNING });
|
||||||
|
const newFace = TestFactory.face({ faceId: newAssetFace.id, embedding });
|
||||||
|
await factory.withAsset(newAsset).withAssetFace(newAssetFace).withFaces(newFace).create();
|
||||||
|
await context.person.reassignFaces({ newPersonId: existingPerson.id, faceIds: assetFaces });
|
||||||
|
|
||||||
|
const result = await sut.handleRecognizeFaces({ id: newAssetFace.id, deferred: false });
|
||||||
|
|
||||||
|
expect(result).toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
|
const after = await context.db
|
||||||
|
.selectFrom('asset_faces')
|
||||||
|
.select('asset_faces.personId')
|
||||||
|
.where('asset_faces.id', '=', newAssetFace.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(after.personId).toEqual(existingPerson.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not assign face to an existing person if asset is older than person', async () => {
|
||||||
|
const user = TestFactory.user();
|
||||||
|
const assetCreatedAt = new Date('2020-02-23T05:06:29.716Z');
|
||||||
|
const birthDate = new Date(assetCreatedAt.getTime() + 3600 * 1000 * 365);
|
||||||
|
const existingPerson = TestFactory.person({ ownerId: user.id, birthDate });
|
||||||
|
const embedding = newEmbedding();
|
||||||
|
|
||||||
|
let factory = context.getFactory().withUser(user).withPerson(existingPerson);
|
||||||
|
|
||||||
|
const assetFaces: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const existingAsset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const existingAssetFace = TestFactory.assetFace({
|
||||||
|
assetId: existingAsset.id,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
|
});
|
||||||
|
assetFaces.push(existingAssetFace.id);
|
||||||
|
const existingFace = TestFactory.face({ faceId: existingAssetFace.id, embedding });
|
||||||
|
factory = factory.withAsset(existingAsset).withAssetFace(existingAssetFace).withFaces(existingFace);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAsset = TestFactory.asset({ ownerId: user.id, fileCreatedAt: assetCreatedAt });
|
||||||
|
const newAssetFace = TestFactory.assetFace({ assetId: newAsset.id, sourceType: SourceType.MACHINE_LEARNING });
|
||||||
|
const newFace = TestFactory.face({ faceId: newAssetFace.id, embedding });
|
||||||
|
await factory.withAsset(newAsset).withAssetFace(newAssetFace).withFaces(newFace).create();
|
||||||
|
await context.person.reassignFaces({ newPersonId: existingPerson.id, faceIds: assetFaces });
|
||||||
|
|
||||||
|
const result = await sut.handleRecognizeFaces({ id: newAssetFace.id, deferred: false });
|
||||||
|
|
||||||
|
expect(result).toBe(JobStatus.SKIPPED);
|
||||||
|
|
||||||
|
const after = await context.db
|
||||||
|
.selectFrom('asset_faces')
|
||||||
|
.select('asset_faces.personId')
|
||||||
|
.where('asset_faces.id', '=', newAssetFace.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(after.personId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
36
server/test/repositories/person.repository.mock.ts
Normal file
36
server/test/repositories/person.repository.mock.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { PersonRepository } from 'src/repositories/person.repository';
|
||||||
|
import { RepositoryInterface } from 'src/types';
|
||||||
|
import { Mocked, vitest } from 'vitest';
|
||||||
|
|
||||||
|
export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepository>> => {
|
||||||
|
return {
|
||||||
|
reassignFaces: vitest.fn(),
|
||||||
|
unassignFaces: vitest.fn(),
|
||||||
|
delete: vitest.fn(),
|
||||||
|
deleteFaces: vitest.fn(),
|
||||||
|
getAllFaces: vitest.fn(),
|
||||||
|
getAll: vitest.fn(),
|
||||||
|
getAllForUser: vitest.fn(),
|
||||||
|
getAllWithoutFaces: vitest.fn(),
|
||||||
|
getFaces: vitest.fn(),
|
||||||
|
getFaceById: vitest.fn(),
|
||||||
|
getFaceByIdWithAssets: vitest.fn(),
|
||||||
|
reassignFace: vitest.fn(),
|
||||||
|
getById: vitest.fn(),
|
||||||
|
getByName: vitest.fn(),
|
||||||
|
getDistinctNames: vitest.fn(),
|
||||||
|
getStatistics: vitest.fn(),
|
||||||
|
getNumberOfPeople: vitest.fn(),
|
||||||
|
create: vitest.fn(),
|
||||||
|
createAll: vitest.fn(),
|
||||||
|
refreshFaces: vitest.fn(),
|
||||||
|
update: vitest.fn(),
|
||||||
|
updateAll: vitest.fn(),
|
||||||
|
getFacesByIds: vitest.fn(),
|
||||||
|
getRandomFace: vitest.fn(),
|
||||||
|
getLatestFaceDate: vitest.fn(),
|
||||||
|
createAssetFace: vitest.fn(),
|
||||||
|
deleteAssetFace: vitest.fn(),
|
||||||
|
softDeleteAssetFaces: vitest.fn(),
|
||||||
|
};
|
||||||
|
};
|
@ -12,6 +12,12 @@ export const newUuids = () =>
|
|||||||
export const newDate = () => new Date();
|
export const newDate = () => new Date();
|
||||||
export const newUpdateId = () => 'uuid-v7';
|
export const newUpdateId = () => 'uuid-v7';
|
||||||
export const newSha1 = () => Buffer.from('this is a fake hash');
|
export const newSha1 = () => Buffer.from('this is a fake hash');
|
||||||
|
export const newEmbedding = () => {
|
||||||
|
const embedding = Array.from({ length: 512 })
|
||||||
|
.fill(0)
|
||||||
|
.map(() => Math.random());
|
||||||
|
return '[' + embedding + ']';
|
||||||
|
};
|
||||||
|
|
||||||
const authFactory = ({ apiKey, ...user }: Partial<AuthUser> & { apiKey?: Partial<AuthApiKey> } = {}) => {
|
const authFactory = ({ apiKey, ...user }: Partial<AuthUser> & { apiKey?: Partial<AuthApiKey> } = {}) => {
|
||||||
const auth: AuthDto = {
|
const auth: AuthDto = {
|
||||||
|
@ -58,6 +58,7 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository
|
|||||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||||
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
|
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
|
||||||
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
|
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
|
||||||
|
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
|
||||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||||
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||||
@ -197,7 +198,7 @@ export const newTestService = <T extends BaseService>(
|
|||||||
notification: automock(NotificationRepository, { args: [loggerMock] }),
|
notification: automock(NotificationRepository, { args: [loggerMock] }),
|
||||||
oauth: automock(OAuthRepository, { args: [loggerMock] }),
|
oauth: automock(OAuthRepository, { args: [loggerMock] }),
|
||||||
partner: automock(PartnerRepository, { strict: false }),
|
partner: automock(PartnerRepository, { strict: false }),
|
||||||
person: automock(PersonRepository, { strict: false }),
|
person: newPersonRepositoryMock(),
|
||||||
process: automock(ProcessRepository, { args: [loggerMock] }),
|
process: automock(ProcessRepository, { args: [loggerMock] }),
|
||||||
search: automock(SearchRepository, { args: [loggerMock], strict: false }),
|
search: automock(SearchRepository, { args: [loggerMock], strict: false }),
|
||||||
// eslint-disable-next-line no-sparse-arrays
|
// eslint-disable-next-line no-sparse-arrays
|
||||||
|
Loading…
x
Reference in New Issue
Block a user