diff --git a/client/src/BaseFilterControls.ts b/client/src/BaseFilterControls.ts index f779e198d..0168e25ff 100644 --- a/client/src/BaseFilterControls.ts +++ b/client/src/BaseFilterControls.ts @@ -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'; @@ -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 @@ -235,6 +239,18 @@ export default abstract class BaseFilterControls { }); } + /** Tracks with enabled classes, none of which reach their confidence threshold. */ + annotationIdsBelowThreshold(types: string[]): AnnotationId[] { + const wanted = new Set(types); + const filters = this.confidenceFilters.value; + return this.sorted.value.filter((annotation) => { + const matching = annotation.confidencePairs.filter(([type]) => wanted.has(type)); + return matching.length > 0 && matching.every(([type, confidence]) => ( + confidence < resolveConfidenceThreshold(filters, type) + )); + }).map(({ id }) => id); + } + updateCheckedTypes(types: string[]) { this.checkedTypes.value = types; } diff --git a/client/src/TrackFilterControls.spec.ts b/client/src/TrackFilterControls.spec.ts index 58b901374..b34a21147 100644 --- a/client/src/TrackFilterControls.spec.ts +++ b/client/src/TrackFilterControls.spec.ts @@ -449,6 +449,36 @@ describe('useAnnotationFilters', () => { expect(cameraStore.getTrack(1).confidencePairs).toEqual([['baz', 0.7]]); }); + it('selects whole hidden tracks without stripping their other class scores', () => { + const { cameraStore, filters } = makePairFixture([ + [['fish', 0.9], ['shark', 0.1]], + [['fish', 0.2], ['shark', 0.1]], + [['shark', 0.1]], + [['fish', 0.5]], + ]); + filters.setConfidenceFilters({ default: 0.5 }); + filters.updateCheckedTypes(['fish']); + const ids = filters.annotationIdsBelowThreshold(['fish']); + expect(ids).toEqual([1]); + expect(cameraStore.getTrack(1).confidencePairs).toEqual([['fish', 0.2], ['shark', 0.1]]); + ids.forEach((id) => cameraStore.removeTracks(id)); + expect(cameraStore.getPossibleTrack(1)).toBeUndefined(); + expect(cameraStore.getTrack(0).confidencePairs).toEqual([['fish', 0.9], ['shark', 0.1]]); + expect(cameraStore.getPossibleTrack(2)).toBeDefined(); + expect(cameraStore.getPossibleTrack(3)).toBeDefined(); + }); + + it('does not classify a visible track by its low-scoring secondary class', () => { + const { filters } = makePairFixture([ + [['fish', 0.9], ['shark', 0.1]], + [['fish', 0.2], ['shark', 0.7]], + [['fish', 0.2], ['shark', 0.1]], + ]); + filters.setConfidenceFilters({ default: 0.5 }); + expect(filters.annotationIdsBelowThreshold(['fish', 'shark'])).toEqual([2]); + expect(filters.annotationIdsBelowThreshold([])).toEqual([]); + }); + it('returns the caller fallback without recomputing flat pair selection', () => { const { cameraStore, filters } = makePairFixture([ [['root', 0.1], ['leaf', 0.9]], diff --git a/client/src/components/DeleteAllScopeDialog.vue b/client/src/components/DeleteAllScopeDialog.vue new file mode 100644 index 000000000..3bdae2a27 --- /dev/null +++ b/client/src/components/DeleteAllScopeDialog.vue @@ -0,0 +1,67 @@ + + + diff --git a/client/src/components/Tracks/TrackList.spec.ts b/client/src/components/Tracks/TrackList.spec.ts index a7249b19f..9e123aa8d 100644 --- a/client/src/components/Tracks/TrackList.spec.ts +++ b/client/src/components/Tracks/TrackList.spec.ts @@ -19,11 +19,14 @@ interface MockTrackFilters { context: { confidencePairIndex: number }; }[]>; hierarchyActive: Ref; + checkedTypes: Ref; + annotationIdsBelowThreshold: ReturnType; } 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', () => ({ @@ -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(), @@ -80,6 +83,8 @@ function mountList( checkedIDs: ref(tracks.map(({ id }) => id)), filteredAnnotations: ref(filtered), hierarchyActive: ref(hierarchyActive), + checkedTypes: ref(['root', 'child']), + annotationIdsBelowThreshold: vi.fn(() => [3]), }; state.cameraStore = { camMap: ref(new Map([['singleCam', { trackStore: undefined }]])), @@ -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; + confirmDeleteAll: () => void; + }; + + it.each([ + ['above', [[1, 2]], 0], + ['below', [[3]], 1], + ['all', [[1, 2, 3]], 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('all'); + 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.annotationIdsBelowThreshold; + expect(below).toHaveBeenCalledTimes(belowCalls); + if (belowCalls) expect(below).toHaveBeenCalledWith(['root', 'child']); + }); + + 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); + }); +}); diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index f3282fb78..0af7c3176 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -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'; @@ -98,6 +98,8 @@ export default defineComponent({ itemHeight: props.compact ? 50 : 70, // in pixels settingsActive: false, columnSettingsActive: false, + showDeleteAll: false, + deleteAllScope: 'all' as ThresholdScope, }); const sortKey = ref('id'); @@ -363,7 +365,33 @@ 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]; + const ids = new Set(scope === 'below' ? [] : checkedDisplayedTracks()); + if (scope !== 'above') { + trackFilters.annotationIdsBelowThreshold(types).forEach((id) => ids.add(id)); + } + // Use the same track deletion path for every scope (including group and selection cleanup). + removeTrack([...ids], true); + } + async function multiDelete() { + if (virtualListItems.value.length > 0 + && checkedDisplayedTracks().length === virtualListItems.value.length) { + data.deleteAllScope = 'all'; + data.showDeleteAll = true; + return; + } const tracksDisplayed: number[] = []; const text = ['Do you want to delete the following tracks:']; let count = 0; @@ -455,6 +483,7 @@ export default defineComponent({ virtualListItems, setVirtualListRef, multiDelete, + confirmDeleteAll, sortKey, sortDirection, handleSort, @@ -473,6 +502,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" @@ -504,6 +534,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" diff --git a/client/src/components/Tracks/bottombar/BottomBarTrackListView.vue b/client/src/components/Tracks/bottombar/BottomBarTrackListView.vue index 934eaea6b..a7fe8552e 100644 --- a/client/src/components/Tracks/bottombar/BottomBarTrackListView.vue +++ b/client/src/components/Tracks/bottombar/BottomBarTrackListView.vue @@ -2,11 +2,12 @@ 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 }, @@ -14,6 +15,7 @@ export default defineComponent({ 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 }, @@ -305,5 +307,11 @@ export default defineComponent({ /> + diff --git a/client/src/components/Tracks/sidebar/SideBarTrackListView.vue b/client/src/components/Tracks/sidebar/SideBarTrackListView.vue index 5a9819a23..5940e168d 100644 --- a/client/src/components/Tracks/sidebar/SideBarTrackListView.vue +++ b/client/src/components/Tracks/sidebar/SideBarTrackListView.vue @@ -2,11 +2,12 @@ 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 }, @@ -14,6 +15,7 @@ export default defineComponent({ 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 }, @@ -153,5 +155,11 @@ export default defineComponent({ /> + diff --git a/docs/UI-Track-List.md b/docs/UI-Track-List.md index 3b0903847..6e687a131 100644 --- a/docs/UI-Track-List.md +++ b/docs/UI-Track-List.md @@ -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).