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): deduplication UI #9540

Merged
merged 45 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2649e48
feat(web): deduplication UI
alextran1502 May 16, 2024
a36e7fb
fix: missing authenticated decorator
alextran1502 May 16, 2024
9645b52
Merge branch 'main' of github.com:immich-app/immich into feat/web-dup…
alextran1502 May 17, 2024
3647ebb
merge main
alextran1502 May 17, 2024
6713115
utilities menu
alextran1502 May 17, 2024
ecc3469
Merge branch 'main' of github.com:immich-app/immich into feat/web-dup…
alextran1502 May 17, 2024
aa92329
Merge branch 'main' of github.com:immich-app/immich into feat/web-dup…
alextran1502 May 17, 2024
2bd8c94
router
alextran1502 May 17, 2024
e330b7f
Merge branch 'main' of github.com:immich-app/immich into feat/web-dup…
alextran1502 May 17, 2024
aa62282
naming convention
alextran1502 May 17, 2024
5341422
get assets
alextran1502 May 17, 2024
02b6275
Merge branch 'main' of github.com:immich-app/immich into feat/web-dup…
alextran1502 May 18, 2024
74c4987
openapi
alextran1502 May 18, 2024
d683b63
Merge branch 'main' of github.com:immich-app/immich into feat/web-dup…
alextran1502 May 18, 2024
20f8c66
update
alextran1502 May 18, 2024
d168b13
building out controller
alextran1502 May 18, 2024
86425ef
action button
alextran1502 May 19, 2024
7d7033f
UI work
alextran1502 May 19, 2024
659478d
color
alextran1502 May 20, 2024
f4e2e26
chip
alextran1502 May 20, 2024
89e3282
functionalities
alextran1502 May 20, 2024
7de0ab7
finish ui
alextran1502 May 20, 2024
468a80a
implemented functionality
alextran1502 May 21, 2024
dfdfff2
notification
alextran1502 May 21, 2024
621c6b5
merge main
alextran1502 May 21, 2024
824aff3
Single instance of duplication
alextran1502 May 21, 2024
3bfa0f4
merge main
alextran1502 May 21, 2024
310fd72
Update web/src/lib/components/admin-page/settings/machine-learning-se…
alextran1502 May 21, 2024
ecb08a3
pr feedback
alextran1502 May 21, 2024
bd6bc6c
Merge branch 'feat/web-duplication-detection-ui' of github.com:immich…
alextran1502 May 21, 2024
83256c1
pr feedback & openapi
alextran1502 May 21, 2024
50265fd
pr feedback and clean up
alextran1502 May 22, 2024
f8df214
merge main
alextran1502 May 22, 2024
ac940d8
using s
alextran1502 May 22, 2024
bc6b055
controller signature
alextran1502 May 22, 2024
91e577e
linting
alextran1502 May 22, 2024
2c161a0
merge main
alextran1502 May 22, 2024
aea6909
remove generated file from pr
alextran1502 May 22, 2024
5bca35a
refactor
alextran1502 May 22, 2024
c723db6
fix sort issue
alextran1502 May 22, 2024
b870524
pr feedback
alextran1502 May 22, 2024
d907a93
pr feedback done
alextran1502 May 23, 2024
bb48d3a
remove ignore openapi file
alextran1502 May 23, 2024
1a9d21b
remove ignore openapi file
alextran1502 May 23, 2024
e607958
pr feedback
alextran1502 May 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion mobile/openapi/lib/model/asset_bulk_update_dto.dart

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

4 changes: 4 additions & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -6957,6 +6957,10 @@
"dateTimeOriginal": {
"type": "string"
},
"duplicateId": {
"nullable": true,
"type": "string"
},
"ids": {
"items": {
"format": "uuid",
Expand Down
1 change: 1 addition & 0 deletions open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export type AssetBulkDeleteDto = {
};
export type AssetBulkUpdateDto = {
dateTimeOriginal?: string;
duplicateId?: string | null;
ids: string[];
isArchived?: boolean;
isFavorite?: boolean;
Expand Down
3 changes: 3 additions & 0 deletions server/src/dtos/asset.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {

@ValidateBoolean({ optional: true })
removeParent?: boolean;

@Optional()
duplicateId?: string | null;
}

export class UpdateAssetDto extends UpdateAssetBase {
Expand Down
6 changes: 6 additions & 0 deletions server/src/dtos/duplicate.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IsNotEmpty } from 'class-validator';
import { groupBy } from 'lodash';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';

Expand All @@ -6,6 +7,11 @@ export class DuplicateResponseDto {
assets!: AssetResponseDto[];
}

export class ResolveDuplicatesDto {
@IsNotEmpty()
assetIds!: string[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need @ValidateUUID({ each: true }) here

}

export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateResponseDto[] {
const result = [];

Expand Down
7 changes: 7 additions & 0 deletions web/src/lib/components/admin-page/jobs/jobs-panel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { handleError } from '$lib/utils/handle-error';
import { JobCommand, JobName, sendJobCommand, type AllJobStatusResponseDto, type JobCommandDto } from '@immich/sdk';
import {
mdiContentDuplicate,
mdiFaceRecognition,
mdiFileJpgBox,
mdiFileXmlBox,
Expand Down Expand Up @@ -88,6 +89,12 @@
subtitle: 'Run machine learning on assets to support smart search',
disabled: !$featureFlags.smartSearch,
},
[JobName.DuplicateDetection]: {
icon: mdiContentDuplicate,
title: getJobName(JobName.DuplicateDetection),
subtitle: 'Run machine learning on assets to detect similar images. Relies on Smart Search',
disabled: !$featureFlags.duplicateDetection,
},
[JobName.FaceDetection]: {
icon: mdiFaceRecognition,
title: getJobName(JobName.FaceDetection),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,40 @@
</div>
</SettingAccordion>

<SettingAccordion
key="duplicate-detection"
title="Duplicate Detection"
subtitle="Use CLIP embeddings to find likely duplicates"
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-duplicate-detection"
title="ENABLED"
subtitle="If disabled, exactly identical assets will still be de-duplicated."
bind:checked={config.machineLearning.duplicateDetection.enabled}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
/>

<hr />

<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MAX DETECTION DISTANCE"
bind:value={config.machineLearning.duplicateDetection.maxDistance}
step="0.01"
min={0.001}
Comment on lines +101 to +102
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the minimum value is .001, we probably want to allow more granular steps as well, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would agree, but @mertalev said not to when we lowered the minimum on the backend and UI.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I plan to test, fine-tune, and enable this feature by default in the subsequent PR. THis PR is dedicated mainly to UI work

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was originally against this because 0.001 increments are too subtle unless you're at the very bottom of the range and add more clicks. But it's fine since you can hold the arrow to move it quickly anyway.

Copy link
Member

@NicholasFlamy NicholasFlamy May 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
step="0.01"
min={0.001}
step="0.001"
min={0.001}

I think this change will do it. (This is just the UI for raising and lowering the value, not min max or default.)

Copy link
Member

@danieldietzler danieldietzler May 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was originally against this because 0.001 increments are too subtle unless you're at the very bottom of the range and add more clicks. But it's fine since you can hold the arrow to move it quickly anyway.

I've been thinking about this as well, and there are also options in between .001 and .01. Maybe something like .005 could also be feasible? Ultimately it probably comes down to testing different options though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.001 will get rid of the annoying popup about the two nearest valid values though. So for now changing it to 0.001 and coming back to it later would prevent an annoying popup.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alextran1502 you want to stick with 0.01 for now?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danieldietzler yes, I can modify it in subsequent PR after fine-tuning the value

max={0.1}
desc="Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives."
disabled={disabled ||
!config.machineLearning.enabled ||
!config.machineLearning.clip.enabled ||
!config.machineLearning.duplicateDetection.enabled}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be able to just use a feature flag here instead. $featureFlag.duplicateDetection

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NicholasFlamy What was the reason you didn't use the feature flag? I remember you have a PR put in for this, correct?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the feature flag to the original backend PR. I didn't think to use the feature flag here (mainly I forgot about this part when I added that flag). So I think a feature flag could replace all of that.

Suggested change
!config.machineLearning.enabled ||
!config.machineLearning.clip.enabled ||
!config.machineLearning.duplicateDetection.enabled}
$featureFlag.duplicateDetection

I haven't tested this, either I'll make the change on my test instance or if it looks good you could commit it and I'll test it, but I won't get to testing either option until tomorrow.

isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance}
/>
</div>
</SettingAccordion>

<SettingAccordion
key="facial-recognition"
title="Facial Recognition"
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/components/elements/buttons/button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10',
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
red: 'bg-red-500 text-white enabled:hover:bg-red-400',
green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90',
green: 'bg-green-400 text-gray-800 enabled:hover:bg-green-400/90',
gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
'transparent-gray':
'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25',
Expand Down
10 changes: 10 additions & 0 deletions web/src/lib/components/shared-components/side-bar/side-bar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
mdiMapOutline,
mdiTrashCan,
mdiTrashCanOutline,
mdiToolbox,
mdiToolboxOutline,
} from '@mdi/js';
import LoadingSpinner from '../loading-spinner.svelte';
import StatusBox from '../status-box.svelte';
Expand All @@ -42,6 +44,7 @@
let isPhotosSelected: boolean;
let isSharingSelected: boolean;
let isTrashSelected: boolean;
let isUtilitiesSelected: boolean;
</script>

<SideBarSection>
Expand Down Expand Up @@ -136,6 +139,13 @@
</svelte:fragment>
</SideBarLink>

<SideBarLink
title="Utilities"
routeId="/(user)/utilities"
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>

<SideBarLink
title="Archive"
routeId="/(user)/archive"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { s } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { sortBy } from 'lodash-es';

export let duplicate: DuplicateResponseDto;
export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;

let selectedAssetIds = new Set<string>();

$: trashCount = duplicate.assets.length - selectedAssetIds.size;

onMount(() => {
const suggestedAsset = sortBy(duplicate.assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();

if (!suggestedAsset) {
selectedAssetIds = new Set(duplicate.assets[0].id);
return;
}

selectedAssetIds.add(suggestedAsset.id);
selectedAssetIds = selectedAssetIds;
});

const onSelectAsset = (asset: AssetResponseDto) => {
if (selectedAssetIds.has(asset.id)) {
selectedAssetIds.delete(asset.id);
} else {
selectedAssetIds.add(asset.id);
}

selectedAssetIds = selectedAssetIds;
};

const handleResolve = () => {
const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
const duplicateAssetIds = duplicate.assets.map((asset) => asset.id);
onResolve(duplicateAssetIds, trashIds);
};
</script>

<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-[900px] m-auto mb-16">
<div class="flex flex-wrap gap-1 place-items-center place-content-center px-4 pt-4">
{#each duplicate.assets as asset, index (index)}
{@const isSelected = selectedAssetIds.has(asset.id)}
{@const isFromExternalLibrary = !!asset.libraryId}
{@const assetData = JSON.stringify(asset, null, 2)}

<div class="relative">
<button on:click={() => onSelectAsset(asset)} class="block relative">
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
alt={asset.id}
title={`${assetData}`}
class={`w-[250px] h-[250px] object-cover rounded-t-xl border-t-[4px] border-l-[4px] border-r-[4px] border-gray-300 ${isSelected ? 'border-immich-primary dark:border-immich-dark-primary' : 'dark:border-gray-800'} transition-all`}
draggable="false"
/>

<!-- OVERLAY CHIP -->
<div
class={`absolute bottom-2 right-3 ${isSelected ? 'bg-green-400/90' : 'bg-red-300/90'} px-4 py-1 rounded-xl text-xs font-semibold`}
>
{isSelected ? 'Keep' : 'Trash'}
</div>

<!-- EXTERNAL LIBRARY CHIP-->
{#if isFromExternalLibrary}
<div
class="absolute top-2 right-3 bg-immich-primary/90 px-4 py-1 rounded-xl text-xs font-semibold text-white"
>
External
</div>
{/if}
</button>

<!-- ASSET INFO-->
<table
class={`text-xs w-full rounded-b-xl font-semibold ${isSelected ? 'bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black' : 'bg-gray-200 dark:bg-gray-800 dark:text-white'} mt-0 transition-all`}
>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
>
<td>{asset.originalFileName}</td>
</tr>

<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center`}
>
<td>{getAssetResolution(asset)} - {getFileSize(asset)}</td>
</tr>

<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
>
<td>
{#await getAllAlbums({ assetId: asset.id })}
Scanning for album...
{:then albums}
{#if albums.length === 0}
Not in any album
{:else}
In {albums.length} album{s(albums.length)}
{/if}
{/await}
</td>
</tr>
</table>
</div>
{/each}
</div>

<!-- CONFIRM BUTTONS -->
<div class="flex gap-4 my-4 border-transparent w-full justify-end p-4 h-[85px]">
{#if trashCount === 0}
<Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}
><Icon path={mdiCheck} size="20" />Keep All
</Button>
{:else}
<Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}
><Icon path={mdiTrashCanOutline} size="20" />{trashCount === duplicate.assets.length
? 'Trash All'
: `Trash ${trashCount}`}
</Button>
{/if}
</div>
</div>
18 changes: 18 additions & 0 deletions web/src/lib/components/utilities-page/utilities-menu.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script lang="ts">
import { mdiContentDuplicate } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
</script>

<a href={AppRoute.DUPLICATES}>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="text-xs font-medium p-4">ORGANIZE YOUR LIBRARY</p>

<button class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex gap-4 p-4">
<span
><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
Review duplicates
</button>
</div>
</a>
3 changes: 3 additions & 0 deletions web/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export enum AppRoute {
AUTH_REGISTER = '/auth/register',
AUTH_CHANGE_PASSWORD = '/auth/change-password',
AUTH_ONBOARDING = '/auth/onboarding',

UTILITIES = '/utilities',
DUPLICATES = '/utilities/duplicates',
}

export enum ProjectionType {
Expand Down
Loading
Loading