Compare commits

...

5 Commits

Author SHA1 Message Date
bwees
9bbc1937a1 chore: revise tests 2026-02-03 13:05:40 -06:00
bwees
1ff1992d51 chore: refactor peopleWithFaces 2026-02-03 12:44:16 -06:00
bwees
f61fcf798e fix: transform all face boxes 2026-02-03 12:22:31 -06:00
bwees
be67147766 chore: wip tests 2026-02-03 11:55:06 -06:00
bwees
8ebba759d3 fix: handle edits when creating face 2026-01-30 18:08:37 -06:00
6 changed files with 884 additions and 32 deletions

View File

@@ -0,0 +1,174 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { personStub } from 'test/fixtures/person.stub';
describe('mapAsset', () => {
describe('peopleWithFaces', () => {
it('should transform all faces when a person has multiple faces in the same image', () => {
const face1 = {
...faceStub.primaryFace1,
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
const face2 = {
...faceStub.primaryFace1,
id: 'assetFaceId-second',
boundingBoxX1: 300,
boundingBoxY1: 400,
boundingBoxX2: 400,
boundingBoxY2: 500,
imageWidth: 1000,
imageHeight: 800,
};
const asset = {
...assetStub.withCropEdit,
faces: [face1, face2],
exifInfo: {
exifImageWidth: 1000,
exifImageHeight: 800,
},
};
const result = mapAsset(asset as any);
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(1);
expect(result.people![0].faces).toHaveLength(2);
// Verify that both faces have been transformed (bounding boxes adjusted for crop)
const firstFace = result.people![0].faces[0];
const secondFace = result.people![0].faces[1];
// After crop (x: 216, y: 1512), the coordinates should be adjusted
// Faces outside the crop area will be clamped
expect(firstFace.boundingBoxX1).toBe(-116); // 100 - 216 = -116
expect(firstFace.boundingBoxY1).toBe(-1412); // 100 - 1512 = -1412
expect(firstFace.boundingBoxX2).toBe(-16); // 200 - 216 = -16
expect(firstFace.boundingBoxY2).toBe(-1312); // 200 - 1512 = -1312
expect(secondFace.boundingBoxX1).toBe(84); // 300 - 216
expect(secondFace.boundingBoxY1).toBe(-1112); // 400 - 1512 = -1112
expect(secondFace.boundingBoxX2).toBe(184); // 400 - 216
expect(secondFace.boundingBoxY2).toBe(-1012); // 500 - 1512 = -1012
});
it('should transform unassigned faces with edits and dimensions', () => {
const unassignedFace = {
...faceStub.noPerson1,
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
const asset = {
...assetStub.withCropEdit,
faces: [unassignedFace],
exifInfo: {
exifImageWidth: 1000,
exifImageHeight: 800,
},
edits: [
{
action: AssetEditAction.Crop,
parameters: { x: 50, y: 50, width: 500, height: 400 },
},
],
};
const result = mapAsset(asset as any);
expect(result.unassignedFaces).toBeDefined();
expect(result.unassignedFaces).toHaveLength(1);
// Verify that unassigned face has been transformed
const face = result.unassignedFaces![0];
expect(face.boundingBoxX1).toBe(50); // 100 - 50
expect(face.boundingBoxY1).toBe(50); // 100 - 50
expect(face.boundingBoxX2).toBe(150); // 200 - 50
expect(face.boundingBoxY2).toBe(150); // 200 - 50
});
it('should handle multiple people each with multiple faces', () => {
const person1Face1 = {
...faceStub.primaryFace1,
id: 'face-1-1',
person: personStub.withName,
personId: personStub.withName.id,
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
const person1Face2 = {
...faceStub.primaryFace1,
id: 'face-1-2',
person: personStub.withName,
personId: personStub.withName.id,
boundingBoxX1: 300,
boundingBoxY1: 300,
boundingBoxX2: 400,
boundingBoxY2: 400,
imageWidth: 1000,
imageHeight: 800,
};
const person2Face1 = {
...faceStub.mergeFace1,
id: 'face-2-1',
person: personStub.mergePerson,
personId: personStub.mergePerson.id,
boundingBoxX1: 500,
boundingBoxY1: 100,
boundingBoxX2: 600,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
const asset = {
...assetStub.withCropEdit,
faces: [person1Face1, person1Face2, person2Face1],
exifInfo: {
exifImageWidth: 1000,
exifImageHeight: 800,
},
edits: [],
};
const result = mapAsset(asset as any);
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(2);
const person1 = result.people!.find((p) => p.id === personStub.withName.id);
const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id);
expect(person1).toBeDefined();
expect(person1!.faces).toHaveLength(2);
// No edits, so coordinates should be unchanged
expect(person1!.faces[0].boundingBoxX1).toBe(100);
expect(person1!.faces[0].boundingBoxY1).toBe(100);
expect(person1!.faces[1].boundingBoxX1).toBe(300);
expect(person1!.faces[1].boundingBoxY1).toBe(300);
expect(person2).toBeDefined();
expect(person2!.faces).toHaveLength(1);
expect(person2!.faces[0].boundingBoxX1).toBe(500);
expect(person2!.faces[0].boundingBoxY1).toBe(100);
});
});
});

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Selectable } from 'kysely';
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { AssetFace, AssetFile, Exif, Person, Stack, Tag, User } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
@@ -193,26 +193,35 @@ export type AssetMapOptions = {
auth?: AuthDto;
};
// TODO: this is inefficient
const peopleWithFaces = (
faces?: AssetFace[],
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = [];
if (faces) {
for (const face of faces) {
if (face.person) {
const existingPersonEntry = result.find((item) => item.id === face.person!.id);
if (existingPersonEntry) {
existingPersonEntry.faces.push(face);
} else {
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face, edits, assetDimensions)] });
}
if (!faces) {
return [];
}
const peopleFaces: Map<Person, AssetFaceWithoutPersonResponseDto[]> = new Map();
for (const face of faces) {
if (face.person) {
if (!peopleFaces.has(face.person)) {
peopleFaces.set(face.person, []);
}
peopleFaces.get(face.person)!.push(mapFacesWithoutPerson(face, edits, assetDimensions));
}
}
const result: PersonWithFacesResponseDto[] = [];
for (const [person, faceDtos] of peopleFaces.entries()) {
result.push({
...mapPerson(person),
faces: faceDtos,
});
}
return result;
};
@@ -275,7 +284,9 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
unassignedFaces: entity.faces
?.filter((face) => !face.person)
.map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)),
checksum: hexOrBufferToBase64(entity.checksum)!,
stack: withStack ? mapStack(entity) : undefined,
isOffline: entity.isOffline,

View File

@@ -44,6 +44,7 @@ import { getDimensions } from 'src/utils/asset.util';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { isFacialRecognitionEnabled } from 'src/utils/misc';
import { Point, transformPoints } from 'src/utils/transform';
@Injectable()
export class PersonService extends BaseService {
@@ -634,15 +635,50 @@ export class PersonService extends BaseService {
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]);
const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true });
if (!asset) {
throw new NotFoundException('Asset not found');
}
const edits = asset.edits || [];
let p1: Point = { x: dto.x, y: dto.y };
let p2: Point = { x: dto.x + dto.width, y: dto.y + dto.height };
// the coordinates received from the client are based on the edited preview image
// we need to convert them to the coordinate space of the original unedited image
if (edits.length > 0) {
if (!asset.width || !asset.height || !asset.exifInfo?.exifImageWidth || !asset.exifInfo?.exifImageHeight) {
throw new BadRequestException('Asset does not have valid dimensions');
}
// convert from preview to full dimensions
const scaleFactor = asset.width / dto.imageWidth;
p1 = { x: p1.x * scaleFactor, y: p1.y * scaleFactor };
p2 = { x: p2.x * scaleFactor, y: p2.y * scaleFactor };
const {
points: [invertedP1, invertedP2],
} = transformPoints([p1, p2], edits, { width: asset.width, height: asset.height }, { inverse: true });
// make sure p1 is top-left and p2 is bottom-right
p1 = { x: Math.min(invertedP1.x, invertedP2.x), y: Math.min(invertedP1.y, invertedP2.y) };
p2 = { x: Math.max(invertedP1.x, invertedP2.x), y: Math.max(invertedP1.y, invertedP2.y) };
// now coordinates are in original image space
dto.imageHeight = asset.exifInfo.exifImageHeight;
dto.imageWidth = asset.exifInfo.exifImageWidth;
}
await this.personRepository.createAssetFace({
personId: dto.personId,
assetId: dto.assetId,
imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth,
boundingBoxX1: dto.x,
boundingBoxX2: dto.x + dto.width,
boundingBoxY1: dto.y,
boundingBoxY2: dto.y + dto.height,
boundingBoxX1: Math.round(p1.x),
boundingBoxX2: Math.round(p2.x),
boundingBoxY1: Math.round(p1.y),
boundingBoxY2: Math.round(p2.y),
sourceType: SourceType.Manual,
});
}

View File

@@ -61,7 +61,7 @@ export const createAffineMatrix = (
);
};
type Point = { x: number; y: number };
export type Point = { x: number; y: number };
type TransformState = {
points: Point[];
@@ -73,29 +73,33 @@ type TransformState = {
* Transforms an array of points through a series of edit operations (crop, rotate, mirror).
* Points should be in absolute pixel coordinates relative to the starting dimensions.
*/
const transformPoints = (
export const transformPoints = (
points: Point[],
edits: AssetEditActionItem[],
startingDimensions: ImageDimensions,
{ inverse = false } = {},
): TransformState => {
let currentWidth = startingDimensions.width;
let currentHeight = startingDimensions.height;
let transformedPoints = [...points];
// Handle crop first
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x - cropX,
y: p.y - cropY,
}));
currentWidth = cropWidth;
currentHeight = cropHeight;
// Handle crop first if not inverting
if (!inverse) {
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x - cropX,
y: p.y - cropY,
}));
currentWidth = cropWidth;
currentHeight = cropHeight;
}
}
// Apply rotate and mirror transforms
for (const edit of edits) {
const editSequence = inverse ? edits.toReversed() : edits;
for (const edit of editSequence) {
let matrix: Matrix = identity();
if (edit.action === 'rotate') {
const angleDegrees = edit.parameters.angle;
@@ -105,7 +109,7 @@ const transformPoints = (
matrix = compose(
translate(newWidth / 2, newHeight / 2),
rotate(angleRadians),
rotate(inverse ? -angleRadians : angleRadians),
translate(-currentWidth / 2, -currentHeight / 2),
);
@@ -125,6 +129,18 @@ const transformPoints = (
transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p));
}
// Handle crop last if inverting
if (inverse) {
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x + cropX,
y: p.y + cropY,
}));
}
}
return {
points: transformedPoints,
currentWidth,

View File

@@ -6,6 +6,7 @@ import { Stats } from 'node:fs';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
import { AssetEditActionListDto } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
AssetType,
@@ -280,6 +281,11 @@ export class MediumTestContext<S extends BaseService = BaseService> {
const result = await this.get(TagRepository).upsertAssetIds(tagsAssets);
return { tagsAssets, result };
}
async newEdits(assetId: string, dto: AssetEditActionListDto) {
const edits = await this.get(AssetEditRepository).replaceAll(assetId, dto.edits);
return { edits };
}
}
export class SyncTestContext extends MediumTestContext<SyncService> {

View File

@@ -1,5 +1,9 @@
import { Kysely } from 'kysely';
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
import { AssetFaceCreateDto } from 'src/dtos/person.dto';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonRepository } from 'src/repositories/person.repository';
@@ -15,7 +19,7 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(PersonService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, PersonRepository],
real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository],
mock: [LoggingRepository, StorageRepository],
});
};
@@ -77,4 +81,609 @@ describe(PersonService.name, () => {
expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath);
});
});
describe('createFace', () => {
it('should store and retrieve the face as-is when there are no edits', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 200,
imageHeight: 200,
x: 50,
y: 50,
width: 150,
height: 150,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
// retrieve an asset's faces
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 50,
boundingBoxY1: 50,
boundingBoxX2: 200,
boundingBoxY2: 200,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Crop)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Crop,
parameters: {
x: 50,
y: 50,
width: 150,
height: 200,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 150,
imageHeight: 200,
x: 0,
y: 0,
width: 100,
height: 100,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
// retrieve an asset's faces
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 100,
boundingBoxY2: 100,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toHaveLength(1);
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 50,
boundingBoxY1: 50,
boundingBoxX2: 150,
boundingBoxY2: 150,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Rotate 90)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageWidth: 200, exifImageHeight: 100 });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Rotate,
parameters: {
angle: 90,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 100,
imageHeight: 200,
x: 25,
y: 50,
width: 10,
height: 10,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: expect.closeTo(25, 1),
boundingBoxY1: expect.closeTo(50, 1),
boundingBoxX2: expect.closeTo(35, 1),
boundingBoxY2: expect.closeTo(60, 1),
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 50,
boundingBoxY1: 65,
boundingBoxX2: 60,
boundingBoxY2: 75,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Mirror Horizontal)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 200,
imageHeight: 100,
x: 50,
y: 25,
width: 100,
height: 50,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 50,
boundingBoxY1: 25,
boundingBoxX2: 150,
boundingBoxY2: 75,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 50,
boundingBoxY1: 25,
boundingBoxX2: 150,
boundingBoxY2: 75,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Crop + Rotate)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Crop,
parameters: {
x: 50,
y: 0,
width: 150,
height: 200,
},
},
{
action: AssetEditAction.Rotate,
parameters: {
angle: 90,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 200,
imageHeight: 150,
x: 50,
y: 25,
width: 10,
height: 20,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: expect.closeTo(50, 1),
boundingBoxY1: expect.closeTo(25, 1),
boundingBoxX2: expect.closeTo(60, 1),
boundingBoxY2: expect.closeTo(45, 1),
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 75,
boundingBoxY1: 140,
boundingBoxX2: 95,
boundingBoxY2: 150,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Crop + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Crop,
parameters: {
x: 50,
y: 0,
width: 150,
height: 100,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 150,
imageHeight: 100,
x: 25,
y: 25,
width: 75,
height: 50,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 25,
boundingBoxY1: 25,
boundingBoxX2: 100,
boundingBoxY2: 75,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 100,
boundingBoxY1: 25,
boundingBoxX2: 175,
boundingBoxY2: 75,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Rotate + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 150 });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Rotate,
parameters: {
angle: 90,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 200,
imageHeight: 150,
x: 50,
y: 25,
width: 15,
height: 20,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: expect.closeTo(50, 1),
boundingBoxY1: expect.closeTo(25, 1),
boundingBoxX2: expect.closeTo(65, 1),
boundingBoxY2: expect.closeTo(45, 1),
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 25,
boundingBoxY1: 50,
boundingBoxX2: 45,
boundingBoxY2: 65,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Crop + Rotate + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Crop,
parameters: {
x: 50,
y: 25,
width: 100,
height: 150,
},
},
{
action: AssetEditAction.Rotate,
parameters: {
angle: 270,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 150,
imageHeight: 150,
x: 25,
y: 50,
width: 75,
height: 50,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: expect.closeTo(25, 1),
boundingBoxY1: expect.closeTo(50, 1),
boundingBoxX2: expect.closeTo(100, 1),
boundingBoxY2: expect.closeTo(100, 1),
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 50,
boundingBoxY1: 75,
boundingBoxX2: 100,
boundingBoxY2: 150,
}),
]),
);
});
it('should properly transform the coordinates with multiple mirrors in sequence', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 100 });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Vertical,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 100,
imageHeight: 100,
x: 10,
y: 10,
width: 80,
height: 80,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 10,
boundingBoxY1: 10,
boundingBoxX2: 90,
boundingBoxY2: 90,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 10,
boundingBoxY1: 10,
boundingBoxX2: 90,
boundingBoxY2: 90,
}),
]),
);
});
});
});