Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions client/src/BaseFilterControls.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ref, computed, Ref, watch,
} from 'vue';
import { resolveConfidenceThreshold } from 'dive-common/typeHierarchy';
import type { AnnotationId, ConfidencePair } from './BaseAnnotation';
import { SortedAnnotation } from './BaseAnnotationStore';
import type Group from './Group';
Expand All @@ -15,6 +16,9 @@ interface MarkChangesPendingData {
export type MarkChangesPendingFilter = (data?: MarkChangesPendingData) => void;

export const DefaultConfidence = 0.1;

/** Which annotations a delete-all reaches, relative to each type's threshold. */
export type ThresholdScope = 'above' | 'below' | 'all';
/**
* AnnotationWithContext wraps an annotation with additional information
* such as why the annotation was included or returned by a system
Expand Down Expand Up @@ -235,6 +239,30 @@ export default abstract class BaseFilterControls<T extends Track | Group> {
});
}

/**
* Like removeTypeAnnotations, but reaching past the visible list: 'below'
* removes `types` only where the annotation's confidence for the type is
* under that type's threshold (the annotations the list hides), 'all'
* removes them everywhere. 'above' is the visible list itself.
*/
removeTypeAnnotationsByThreshold(types: string[], scope: ThresholdScope) {
if (scope === 'above') {
this.removeTypeAnnotations(types);
return;
}
const wanted = new Set(types);
const filters = this.confidenceFilters.value;
[...this.sorted.value].forEach((annotation) => {
const matching = annotation.confidencePairs.filter(([type, confidence]) => wanted.has(type)
&& (scope === 'all' || confidence < resolveConfidenceThreshold(filters, type)));
if (matching.length === 0) return;
const remaining = this.removeTypes(annotation.id, matching.map(([type]) => type));
if (remaining.length === 0) {
this.remove(annotation.id);
}
});
}

updateCheckedTypes(types: string[]) {
this.checkedTypes.value = types;
}
Expand Down
29 changes: 29 additions & 0 deletions client/src/TrackFilterControls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,35 @@ describe('useAnnotationFilters', () => {
expect(cameraStore.getTrack(1).confidencePairs).toEqual([['baz', 0.7]]);
});

it('deletes all of a type above, below or regardless of its threshold', () => {
const scoped = () => makePairFixture([
[['bar', 0.9]],
[['bar', 0.05]],
[['bar', 0.05], ['baz', 0.7]],
[['baz', 0.7]],
]);
let { cameraStore, filters } = scoped();
filters.setConfidenceFilters({ default: 0.1, bar: 0.5 });
filters.removeTypeAnnotationsByThreshold(['bar'], 'below');
expect(cameraStore.getPossibleTrack(0)?.confidencePairs).toEqual([['bar', 0.9]]);
expect(cameraStore.getPossibleTrack(1)).toBeUndefined();
expect(cameraStore.getTrack(2).confidencePairs).toEqual([['baz', 0.7]]);
expect(cameraStore.getTrack(3).confidencePairs).toEqual([['baz', 0.7]]);

({ cameraStore, filters } = scoped());
filters.setConfidenceFilters({ default: 0.1, bar: 0.5 });
filters.removeTypeAnnotationsByThreshold(['bar'], 'all');
expect(cameraStore.getPossibleTrack(0)).toBeUndefined();
expect(cameraStore.getPossibleTrack(1)).toBeUndefined();
expect(cameraStore.getTrack(2).confidencePairs).toEqual([['baz', 0.7]]);

({ cameraStore, filters } = scoped());
filters.setConfidenceFilters({ default: 0.1, bar: 0.5 });
filters.removeTypeAnnotationsByThreshold(['bar'], 'above');
expect(cameraStore.getPossibleTrack(0)).toBeUndefined();
expect(cameraStore.getPossibleTrack(1)?.confidencePairs).toEqual([['bar', 0.05]]);
});

it('returns the caller fallback without recomputing flat pair selection', () => {
const { cameraStore, filters } = makePairFixture([
[['root', 0.1], ['leaf', 0.9]],
Expand Down
67 changes: 67 additions & 0 deletions client/src/components/DeleteAllScopeDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import type { ThresholdScope } from '../BaseFilterControls';

/* Deleting everything a list shows: the list only holds what passes the
confidence thresholds, so ask which side of them the delete should reach. */
export default defineComponent({
name: 'DeleteAllScopeDialog',
props: {
value: { type: Boolean, required: true },
scope: { type: String as PropType<ThresholdScope>, required: true },
lead: { type: String, required: true },
},
});
</script>

<template>
<v-dialog
:value="value"
width="420"
@input="$emit('input', $event)"
>
<v-card>
<v-card-title>Delete all tracks?</v-card-title>
<v-card-text>
<p class="mb-2">
{{ lead }}
</p>
<v-radio-group
:value="scope"
class="mt-0"
hide-details
@change="$emit('update:scope', $event)"
>
<v-radio
label="Above the current threshold (what the list shows)"
value="above"
/>
<v-radio
label="Below the current threshold (hidden from the list)"
value="below"
/>
<v-radio
label="All tracks, regardless of threshold"
value="all"
/>
</v-radio-group>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="$emit('input', false)"
>
Cancel
</v-btn>
<v-btn
color="error"
text
@click="$emit('confirm')"
>
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
48 changes: 47 additions & 1 deletion client/src/components/Tracks/TrackList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ interface MockTrackFilters {
context: { confidencePairIndex: number };
}[]>;
hierarchyActive: Ref<boolean>;
checkedTypes: Ref<string[]>;
removeTypeAnnotationsByThreshold: ReturnType<typeof vi.fn>;
}

const state = vi.hoisted(() => ({
cameraStore: null as unknown as MockCameraStore,
trackFilters: null as unknown as MockTrackFilters,
removeTrack: vi.fn(),
}));

vi.mock('dive-common/vue-utilities/prompt-service', () => ({
Expand All @@ -38,7 +41,7 @@ vi.mock('../../provides', () => ({
useEditingMode: () => ref(false),
useHandler: () => ({
trackSplit: vi.fn(),
removeTrack: vi.fn(),
removeTrack: state.removeTrack,
trackAdd: vi.fn(),
trackSelect: vi.fn(),
trackSelectNext: vi.fn(),
Expand Down Expand Up @@ -80,6 +83,8 @@ function mountList(
checkedIDs: ref(tracks.map(({ id }) => id)),
filteredAnnotations: ref(filtered),
hierarchyActive: ref(hierarchyActive),
checkedTypes: ref(['root', 'child']),
removeTypeAnnotationsByThreshold: vi.fn(),
};
state.cameraStore = {
camMap: ref(new Map([['singleCam', { trackStore: undefined }]])),
Expand Down Expand Up @@ -175,3 +180,44 @@ describe('TrackList hierarchy display', () => {
expect(getType).not.toHaveBeenCalled();
});
});

describe('TrackList delete of every listed track', () => {
function mountTwo() {
const tracks = [1, 2].map((id) => new Track(id, {
confidencePairs: [['root', 0.9]],
features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }],
}));
state.removeTrack.mockClear();
return mountList(tracks, [0, 0], false);
}
type ListVm = {
data: { showDeleteAll: boolean; deleteAllScope: string };
multiDelete: () => Promise<void>;
confirmDeleteAll: () => void;
};

it.each([
['above', [[1, 2]], 0],
['below', [], 1],
['all', [[1, 2]], 1],
] as [string, number[][], number][])('asks for the threshold scope and applies %s', async (scope, removed, belowCalls) => {
const vm = mountTwo().vm as unknown as ListVm;
await vm.multiDelete();
expect(vm.data.showDeleteAll).toBe(true);
expect(vm.data.deleteAllScope).toBe('above');
vm.data.deleteAllScope = scope;
vm.confirmDeleteAll();
expect(vm.data.showDeleteAll).toBe(false);
expect(state.removeTrack.mock.calls.map(([ids]) => ids)).toEqual(removed);
const below = state.trackFilters.removeTypeAnnotationsByThreshold;
expect(below).toHaveBeenCalledTimes(belowCalls);
if (belowCalls) expect(below).toHaveBeenCalledWith(['root', 'child'], 'below');
});

it('keeps the plain confirmation for a partial selection', async () => {
const vm = mountTwo().vm as unknown as ListVm;
state.trackFilters.checkedIDs.value = [1];
await vm.multiDelete();
expect(vm.data.showDeleteAll).toBe(false);
});
});
29 changes: 28 additions & 1 deletion client/src/components/Tracks/TrackList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Vue, {

import { usePrompt } from 'dive-common/vue-utilities/prompt-service';
import { AnnotationId } from 'vue-media-annotator/BaseAnnotation';
import { TrackWithContext } from 'vue-media-annotator/BaseFilterControls';
import { TrackWithContext, ThresholdScope } from 'vue-media-annotator/BaseFilterControls';
import type { TrackProjection } from 'vue-media-annotator/TrackProjection';

import { clientSettings } from 'dive-common/store/settings';
Expand Down Expand Up @@ -98,6 +98,8 @@ export default defineComponent({
itemHeight: props.compact ? 50 : 70, // in pixels
settingsActive: false,
columnSettingsActive: false,
showDeleteAll: false,
deleteAllScope: 'above' as ThresholdScope,
});

const sortKey = ref<SortKey>('id');
Expand Down Expand Up @@ -363,7 +365,29 @@ export default defineComponent({
};
}

function checkedDisplayedTracks() {
return virtualListItems.value
.map((item) => item.filteredTrack.annotation.id)
.filter((id) => checkedTrackIdsRef.value.includes(id));
}

/* 'above' is the listed tracks themselves; 'below' reaches the tracks of
the enabled types that the confidence thresholds hide from the list. */
function confirmDeleteAll() {
data.showDeleteAll = false;
const scope = data.deleteAllScope;
const types = [...trackFilters.checkedTypes.value];
if (scope !== 'below') removeTrack(checkedDisplayedTracks(), true);
if (scope !== 'above') trackFilters.removeTypeAnnotationsByThreshold(types, 'below');
}

async function multiDelete() {
if (virtualListItems.value.length > 0
&& checkedDisplayedTracks().length === virtualListItems.value.length) {
data.deleteAllScope = 'above';
data.showDeleteAll = true;
return;
}
const tracksDisplayed: number[] = [];
const text = ['Do you want to delete the following tracks:'];
let count = 0;
Expand Down Expand Up @@ -455,6 +479,7 @@ export default defineComponent({
virtualListItems,
setVirtualListRef,
multiDelete,
confirmDeleteAll,
sortKey,
sortDirection,
handleSort,
Expand All @@ -473,6 +498,7 @@ export default defineComponent({
:new-track-type="newTrackType"
:track-add="trackAdd"
:multi-delete="multiDelete"
:confirm-delete-all="confirmDeleteAll"
:virtual-list-items="virtualListItems"
:get-item-props="getItemProps"
:lock-types="lockTypes"
Expand Down Expand Up @@ -504,6 +530,7 @@ export default defineComponent({
:new-track-type="newTrackType"
:track-add="trackAdd"
:multi-delete="multiDelete"
:confirm-delete-all="confirmDeleteAll"
:virtual-list-items="virtualListItems"
:get-item-props="getItemProps"
:lock-types="lockTypes"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
import { defineComponent, computed } from 'vue';
import { clientSettings } from 'dive-common/store/settings';
import TrackItem from '../TrackItem.vue';
import DeleteAllScopeDialog from '../../DeleteAllScopeDialog.vue';
import { useReadOnlyMode, useTrackFilters, useTrackStyleManager } from '../../../provides';

export default defineComponent({
name: 'BottomBarTrackListView',
components: { TrackItem },
components: { TrackItem, DeleteAllScopeDialog },
props: {
data: { type: Object, required: true },
filteredTracks: { type: Array, required: true },
newTrackMode: { type: String, required: true },
newTrackType: { type: String, required: true },
trackAdd: { type: Function, required: true },
multiDelete: { type: Function, required: true },
confirmDeleteAll: { type: Function, required: true },
virtualListItems: { type: Array, required: true },
getItemProps: { type: Function, required: true },
lockTypes: { type: Boolean, required: true },
Expand Down Expand Up @@ -305,5 +307,11 @@ export default defineComponent({
/>
</template>
</v-virtual-scroll>
<DeleteAllScopeDialog
v-model="data.showDeleteAll"
:scope.sync="data.deleteAllScope"
lead="Every listed track is selected. Delete tracks of the listed types that are:"
@confirm="confirmDeleteAll()"
/>
</div>
</template>
10 changes: 9 additions & 1 deletion client/src/components/Tracks/sidebar/SideBarTrackListView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
import { defineComponent, computed } from 'vue';
import { clientSettings } from 'dive-common/store/settings';
import TrackItem from '../TrackItem.vue';
import DeleteAllScopeDialog from '../../DeleteAllScopeDialog.vue';
import { useReadOnlyMode, useTrackFilters, useTrackStyleManager } from '../../../provides';

export default defineComponent({
name: 'SideBarTrackListView',
components: { TrackItem },
components: { TrackItem, DeleteAllScopeDialog },
props: {
data: { type: Object, required: true },
filteredTracks: { type: Array, required: true },
newTrackMode: { type: String, required: true },
newTrackType: { type: String, required: true },
trackAdd: { type: Function, required: true },
multiDelete: { type: Function, required: true },
confirmDeleteAll: { type: Function, required: true },
virtualListItems: { type: Array, required: true },
getItemProps: { type: Function, required: true },
lockTypes: { type: Boolean, required: true },
Expand Down Expand Up @@ -153,5 +155,11 @@ export default defineComponent({
/>
</template>
</v-virtual-scroll>
<DeleteAllScopeDialog
v-model="data.showDeleteAll"
:scope.sync="data.deleteAllScope"
lead="Every listed track is selected. Delete tracks of the listed types that are:"
@confirm="confirmDeleteAll()"
/>
</div>
</template>
2 changes: 1 addition & 1 deletion docs/UI-Track-List.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
The track list allows for selecting and editing tracks. A selected track will look different depending on whether it's a single detection or a multi-frame track.

* ==:material-cog:== opens track creation settings
* ==:material-delete:=={ .error } deletes all tracks in the track list
* ==:material-delete:=={ .error } deletes the checked tracks in the track list. When every listed track is checked, DIVE asks whether to delete the tracks above the current confidence threshold (what the list shows), the ones below it (hidden from the list), or all of them.
* ==:material-plus: Track/Detection== begins creation of a new annotation.

When [suppression](UI-Suppression.md) is enabled, detections covered by a suppression region on the current frame are omitted from this list (attribute-flagged detections remain listed).
Expand Down
Loading