fix: ensure manually tagged faces have proper source type (#16364)

immich-app/immich#16062 added manual face tagging and deletion, but did
not add a new 'SourceType'. The create faces would default to
'machine-learning' which is incorrect, and has the annoying downside
that they will be wiped when the 'Refresh Faces' job is run.

Handling of non-machine-learning faces was previously added in
immich-app/immich#6455. This PR simply extends it to the new manually
tagged faces.
This commit is contained in:
David Bourgault 2025-02-26 21:53:21 -05:00 committed by GitHub
parent 8fbd650483
commit 4b55888d16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 63 additions and 6 deletions

View File

@ -18,6 +18,7 @@ class AssetFaceCreateDto {
required this.imageHeight, required this.imageHeight,
required this.imageWidth, required this.imageWidth,
required this.personId, required this.personId,
this.sourceType = SourceType.manual,
required this.width, required this.width,
required this.x, required this.x,
required this.y, required this.y,
@ -33,6 +34,8 @@ class AssetFaceCreateDto {
String personId; String personId;
SourceType sourceType;
int width; int width;
int x; int x;
@ -46,6 +49,7 @@ class AssetFaceCreateDto {
other.imageHeight == imageHeight && other.imageHeight == imageHeight &&
other.imageWidth == imageWidth && other.imageWidth == imageWidth &&
other.personId == personId && other.personId == personId &&
other.sourceType == sourceType &&
other.width == width && other.width == width &&
other.x == x && other.x == x &&
other.y == y; other.y == y;
@ -58,12 +62,13 @@ class AssetFaceCreateDto {
(imageHeight.hashCode) + (imageHeight.hashCode) +
(imageWidth.hashCode) + (imageWidth.hashCode) +
(personId.hashCode) + (personId.hashCode) +
(sourceType.hashCode) +
(width.hashCode) + (width.hashCode) +
(x.hashCode) + (x.hashCode) +
(y.hashCode); (y.hashCode);
@override @override
String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]'; String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, sourceType=$sourceType, width=$width, x=$x, y=$y]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -72,6 +77,7 @@ class AssetFaceCreateDto {
json[r'imageHeight'] = this.imageHeight; json[r'imageHeight'] = this.imageHeight;
json[r'imageWidth'] = this.imageWidth; json[r'imageWidth'] = this.imageWidth;
json[r'personId'] = this.personId; json[r'personId'] = this.personId;
json[r'sourceType'] = this.sourceType;
json[r'width'] = this.width; json[r'width'] = this.width;
json[r'x'] = this.x; json[r'x'] = this.x;
json[r'y'] = this.y; json[r'y'] = this.y;
@ -92,6 +98,7 @@ class AssetFaceCreateDto {
imageHeight: mapValueOfType<int>(json, r'imageHeight')!, imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!, imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
personId: mapValueOfType<String>(json, r'personId')!, personId: mapValueOfType<String>(json, r'personId')!,
sourceType: SourceType.fromJson(json[r'sourceType'])!,
width: mapValueOfType<int>(json, r'width')!, width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!, x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!, y: mapValueOfType<int>(json, r'y')!,
@ -147,6 +154,7 @@ class AssetFaceCreateDto {
'imageHeight', 'imageHeight',
'imageWidth', 'imageWidth',
'personId', 'personId',
'sourceType',
'width', 'width',
'x', 'x',
'y', 'y',

View File

@ -25,11 +25,13 @@ class SourceType {
static const machineLearning = SourceType._(r'machine-learning'); static const machineLearning = SourceType._(r'machine-learning');
static const exif = SourceType._(r'exif'); static const exif = SourceType._(r'exif');
static const manual = SourceType._(r'manual');
/// List of all possible values in this [enum][SourceType]. /// List of all possible values in this [enum][SourceType].
static const values = <SourceType>[ static const values = <SourceType>[
machineLearning, machineLearning,
exif, exif,
manual,
]; ];
static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value); static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value);
@ -70,6 +72,7 @@ class SourceTypeTypeTransformer {
switch (data) { switch (data) {
case r'machine-learning': return SourceType.machineLearning; case r'machine-learning': return SourceType.machineLearning;
case r'exif': return SourceType.exif; case r'exif': return SourceType.exif;
case r'manual': return SourceType.manual;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -8301,6 +8301,14 @@
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
}, },
"sourceType": {
"allOf": [
{
"$ref": "#/components/schemas/SourceType"
}
],
"default": "manual"
},
"width": { "width": {
"type": "integer" "type": "integer"
}, },
@ -8317,6 +8325,7 @@
"imageHeight", "imageHeight",
"imageWidth", "imageWidth",
"personId", "personId",
"sourceType",
"width", "width",
"x", "x",
"y" "y"
@ -11952,7 +11961,8 @@
"SourceType": { "SourceType": {
"enum": [ "enum": [
"machine-learning", "machine-learning",
"exif" "exif",
"manual"
], ],
"type": "string" "type": "string"
}, },

View File

@ -529,6 +529,7 @@ export type AssetFaceCreateDto = {
imageHeight: number; imageHeight: number;
imageWidth: number; imageWidth: number;
personId: string; personId: string;
sourceType: SourceType;
width: number; width: number;
x: number; x: number;
y: number; y: number;
@ -3453,7 +3454,8 @@ export enum AlbumUserRole {
} }
export enum SourceType { export enum SourceType {
MachineLearning = "machine-learning", MachineLearning = "machine-learning",
Exif = "exif" Exif = "exif",
Manual = "manual"
} }
export enum AssetTypeEnum { export enum AssetTypeEnum {
Image = "IMAGE", Image = "IMAGE",

2
server/src/db.d.ts vendored
View File

@ -29,7 +29,7 @@ export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive; export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Sourcetype = 'exif' | 'machine-learning'; export type Sourcetype = 'exif' | 'machine-learning' | 'manual';
export type Timestamp = ColumnType<Date, Date | string, Date | string>; export type Timestamp = ColumnType<Date, Date | string, Date | string>;

View File

@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { IsArray, IsEnum, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -194,6 +194,10 @@ export class AssetFaceCreateDto extends AssetFaceUpdateItem {
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
height!: number; height!: number;
@ApiProperty({ type: 'string', enum: SourceType, enumName: 'SourceType' })
@IsEnum(SourceType)
sourceType: SourceType = SourceType.MANUAL;
} }
export class AssetFaceDeleteDto { export class AssetFaceDeleteDto {

View File

@ -228,6 +228,7 @@ export enum AssetStatus {
export enum SourceType { export enum SourceType {
MACHINE_LEARNING = 'machine-learning', MACHINE_LEARNING = 'machine-learning',
EXIF = 'exif', EXIF = 'exif',
MANUAL = 'manual',
} }
export enum ManualJobName { export enum ManualJobName {

View File

@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddManualSourceType1740619600996 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE sourceType ADD VALUE 'manual'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Prior to this migration, manually tagged pictures had the 'machine-learning' type
await queryRunner.query(
`UPDATE "asset_faces" SET "sourceType" = 'machine-learning' WHERE "sourceType" = 'manual';`,
);
// Postgres doesn't allow removing values from enums, we have to recreate the type
await queryRunner.query(`ALTER TYPE sourceType RENAME TO oldSourceType`);
await queryRunner.query(`CREATE TYPE sourceType AS ENUM ('machine-learning', 'exif');`);
await queryRunner.query(`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" DROP DEFAULT;`);
await queryRunner.query(
`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" TYPE sourceType USING "sourceType"::text::sourceType;`,
);
await queryRunner.query(
`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" SET DEFAULT 'machine-learning'::sourceType;`,
);
await queryRunner.query(`DROP TYPE oldSourceType;`);
}
}

View File

@ -736,6 +736,7 @@ export class PersonService extends BaseService {
boundingBoxX2: dto.x + dto.width, boundingBoxX2: dto.x + dto.width,
boundingBoxY1: dto.y, boundingBoxY1: dto.y,
boundingBoxY2: dto.y + dto.height, boundingBoxY2: dto.y + dto.height,
sourceType: dto.sourceType,
}); });
} }

View File

@ -4,7 +4,7 @@
import { notificationController } from '$lib/components/shared-components/notification/notification'; import { notificationController } from '$lib/components/shared-components/notification/notification';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk'; import { getAllPeople, createFace, type PersonResponseDto, SourceType } from '@immich/sdk';
import { Button } from '@immich/ui'; import { Button } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -288,6 +288,7 @@
assetFaceCreateDto: { assetFaceCreateDto: {
assetId, assetId,
personId: person.id, personId: person.id,
sourceType: SourceType.Manual,
...data, ...data,
}, },
}); });