diff --git a/client/dive-common/components/TrackDetailsPanel.spec.ts b/client/dive-common/components/TrackDetailsPanel.spec.ts index cad790370..17d7fb1c5 100644 --- a/client/dive-common/components/TrackDetailsPanel.spec.ts +++ b/client/dive-common/components/TrackDetailsPanel.spec.ts @@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({ displayPairIndex: vi.fn(() => 1), track: null as Track | null, multiSelectList: [] as number[], + editingMultiTrack: false, acceptTrackType: vi.fn(), assignTrackType: vi.fn(), })); @@ -40,7 +41,7 @@ vi.mock('vue-media-annotator/provides', () => ({ typeStyling: ref({ color: (type: string) => `color:${type}` }), }), useEditingGroupId: () => ref(null), - useEditingMultiTrack: () => ref(false), + useEditingMultiTrack: () => ref(state.editingMultiTrack), useGroupFilterControls: () => ({ allTypes: ref([]) }), useCameraStore: () => ({ camMap: ref(new Map([['singleCam', { groupStore: undefined }]])), @@ -81,6 +82,7 @@ describe('TrackDetailsPanel hierarchy summary', () => { beforeEach(() => { state.displayPairIndex.mockReturnValue(1); state.multiSelectList = []; + state.editingMultiTrack = false; state.acceptTrackType.mockClear(); state.assignTrackType.mockClear(); state.track = new Track(1, { @@ -125,6 +127,7 @@ describe('TrackDetailsPanel hierarchy summary', () => { it('routes bulk assignment through the same hierarchy-aware command', () => { state.multiSelectList = [1]; + state.editingMultiTrack = true; const { vm } = mountPanel(); vm.updateMultiTrackType('new leaf'); vm.updateSelectedTracksType(); @@ -133,4 +136,13 @@ describe('TrackDetailsPanel hierarchy summary', () => { replaceType: 'leaf', }); }); + + it('does not bulk-assign the hidden default during ordinary track selection', () => { + const { wrapper, vm } = mountPanel(); + + vm.updateSelectedTracksType(); + + expect(state.assignTrackType).not.toHaveBeenCalled(); + expect(wrapper.text()).not.toContain('Update type for selected tracks'); + }); }); diff --git a/client/dive-common/components/TrackDetailsPanel.vue b/client/dive-common/components/TrackDetailsPanel.vue index a16560c61..153848770 100644 --- a/client/dive-common/components/TrackDetailsPanel.vue +++ b/client/dive-common/components/TrackDetailsPanel.vue @@ -238,6 +238,7 @@ export default defineComponent({ }); function updateSelectedTracksType() { + if (!editingMultiTrack.value) return; selectedTrackList.value.forEach((track) => { const pairIndex = Math.max(trackFilters.displayPairIndex(track, 0), 0); cameraStore.assignTrackType(track.id, multiTrackType.value, { @@ -617,6 +618,7 @@ export default defineComponent({ /> (cameraStore.getTrack(track, camera)), getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track), renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) diff --git a/client/dive-common/typeHierarchy.spec.ts b/client/dive-common/typeHierarchy.spec.ts index 3d2d2d4a5..71a255c8c 100644 --- a/client/dive-common/typeHierarchy.spec.ts +++ b/client/dive-common/typeHierarchy.spec.ts @@ -8,6 +8,7 @@ import { removePair, resolveTypeHierarchy, rewriteHierarchyType, + selectFlatPairIndex, selectPairIndex, setPairConfidence, TypeHierarchyError, @@ -249,6 +250,28 @@ describe('type hierarchy index', () => { }); }); +describe('flat pair selection', () => { + const pairs: [string, number][] = [['top', 0.5], ['fallback', 0.8]]; + + it('uses zero when the default threshold is absent', () => { + expect(selectFlatPairIndex(pairs, { + checkedSet: new Set(['fallback']), + confidenceFilters: {}, + filtersDisabled: false, + preventCascade: false, + })).toBe(1); + }); + + it('keeps the strict Prevent Cascade threshold comparison', () => { + expect(selectFlatPairIndex(pairs, { + checkedSet: new Set(['top', 'fallback']), + confidenceFilters: { top: 0.5, default: 0.1 }, + filtersDisabled: false, + preventCascade: true, + })).toBe(-1); + }); +}); + describe('pair merging', () => { const cases: [Array<[string, number][]>, [string, number][]][] = [ [ diff --git a/client/dive-common/typeHierarchy.ts b/client/dive-common/typeHierarchy.ts index 9d13cb3bc..9c4d0c612 100644 --- a/client/dive-common/typeHierarchy.ts +++ b/client/dive-common/typeHierarchy.ts @@ -23,6 +23,40 @@ export interface TypeHierarchyIndex { ancestors: Readonly>; } +interface FlatPairSelectionOptions { + checkedSet: ReadonlySet; + confidenceFilters: Readonly>; + filtersDisabled: boolean; + preventCascade: boolean; +} + +/** Select the visible classification pair when no hierarchy is active. */ +export function selectFlatPairIndex( + pairs: readonly (readonly [string, number])[], + { + checkedSet, confidenceFilters, filtersDisabled, preventCascade, + }: FlatPairSelectionOptions, +): number { + if (pairs.length === 0) return -1; + if (filtersDisabled) return 0; + const passes = ([type, confidence]: readonly [string, number]) => { + const threshold = Math.max( + confidenceFilters[type] || 0, + confidenceFilters.default || 0, + ); + return checkedSet.has(type) && confidence >= threshold; + }; + if (preventCascade) { + const [type, confidence] = pairs[0]; + const threshold = Math.max( + confidenceFilters[type] || 0, + confidenceFilters.default || 0, + ); + return checkedSet.has(type) && confidence > threshold ? 0 : -1; + } + return pairs.findIndex(passes); +} + // Python orders strings by code point; JS compares UTF-16 units, which sorts astral // names before U+E000-U+FFFF. Compare code points so both platforms agree. function codePointCompare(left: string, right: string): number { diff --git a/client/dive-common/use/useModeManager.spec.ts b/client/dive-common/use/useModeManager.spec.ts index a626948b2..d0340b368 100644 --- a/client/dive-common/use/useModeManager.spec.ts +++ b/client/dive-common/use/useModeManager.spec.ts @@ -59,7 +59,6 @@ function makeHarness(markChangesPending: MarkChangesPending = () => undefined) { remove: () => undefined, markChangesPending: () => undefined, lookupGroups: cameraStore.lookupGroups.bind(cameraStore), - getTrack: (id: AnnotationId, camera = 'singleCam') => cameraStore.getTrack(id, camera), getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id), renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) @@ -199,7 +198,6 @@ function makeSingleCamHarness() { remove: () => undefined, markChangesPending: () => undefined, lookupGroups: cameraStore.lookupGroups.bind(cameraStore), - getTrack: (id: AnnotationId, camera = 'singleCam') => cameraStore.getTrack(id, camera), getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id), renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) diff --git a/client/dive-common/use/useSave.ts b/client/dive-common/use/useSave.ts index 72ee37a6d..e76f30f99 100644 --- a/client/dive-common/use/useSave.ts +++ b/client/dive-common/use/useSave.ts @@ -181,10 +181,12 @@ export default function useSave( globalMetadataPending += 1; } pendingSaveCount.value += 1; - } else if (pendingChangeMaps[cameraName]) { - const pendingChangeMap = pendingChangeMaps[cameraName]; + } else { + const globalDefinition = attribute !== undefined || attributeTrackFilter !== undefined; + const pendingChangeMap = pendingChangeMaps[cameraName] + ?? (globalDefinition ? Object.values(pendingChangeMaps)[0] : undefined); - if (!readonlyMode.value) { + if (pendingChangeMap && !readonlyMode.value) { if (track !== undefined) { _updatePendingChangeMap( track.trackId, diff --git a/client/dive-common/use/useSaveClassification.spec.ts b/client/dive-common/use/useSaveClassification.spec.ts index c6f9279bd..bba5d00c3 100644 --- a/client/dive-common/use/useSaveClassification.spec.ts +++ b/client/dive-common/use/useSaveClassification.spec.ts @@ -3,6 +3,7 @@ import { ref } from 'vue'; import CameraStore from 'vue-media-annotator/CameraStore'; import Track, { Feature, TrackData } from 'vue-media-annotator/track'; +import type { Attribute } from 'vue-media-annotator/use/AttributeTypes'; import useSave from './useSave'; @@ -112,4 +113,27 @@ describe('classification save and reload', () => { expect(leftReloaded.confidencePairs).not.toContain(pair) )); }); + + it('persists global attribute definitions after multicamera setup', async () => { + const saveControls = useSave(ref('multicam-dataset'), ref(false)); + saveControls.removeCamera('singleCam'); + saveControls.addCamera('left'); + saveControls.addCamera('right'); + const trackAttribute: Attribute = { + belongs: 'track', datatype: 'text', key: 'track_note', name: 'note', + }; + const detectionAttribute: Attribute = { + belongs: 'detection', datatype: 'text', key: 'detection_state', name: 'state', + }; + + saveControls.markChangesPending({ action: 'upsert', attribute: trackAttribute }); + saveControls.markChangesPending({ action: 'upsert', attribute: detectionAttribute }); + await saveControls.save(); + + expect(apiMocks.saveAttributes).toHaveBeenCalledOnce(); + expect(apiMocks.saveAttributes).toHaveBeenCalledWith('multicam-dataset', { + upsert: [trackAttribute, detectionAttribute], + delete: [], + }); + }); }); diff --git a/client/platform/desktop/backend/serializers/coco.spec.ts b/client/platform/desktop/backend/serializers/coco.spec.ts index df2d40b93..044993a13 100644 --- a/client/platform/desktop/backend/serializers/coco.spec.ts +++ b/client/platform/desktop/backend/serializers/coco.spec.ts @@ -638,15 +638,16 @@ describe('COCO serializer', () => { await serializeFile('/output/filtered.json', source, { ...imageMeta, typeHierarchy: { leaf: 'root' }, - }, new Set(['leaf'])); + }, new Set(['root'])); const out = await fs.readJSON('/output/filtered.json'); - expect(out.annotations[0].dive_confidence_pairs).toEqual([['leaf', 0.8]]); - expect(out.annotations[0].prob).toEqual([0.8, 0]); + // Export filters raw stored names even though hierarchy display resolves this track to leaf. + expect(out.annotations[0].dive_confidence_pairs).toEqual([['root', 0.2]]); + expect(out.annotations[0].prob).toEqual([0.2, 0]); expect(source.tracks[4].confidencePairs).toEqual([['root', 0.2], ['leaf', 0.8]]); await fs.writeJSON('/input/filtered.json', out); const [parsed] = await parseFile('/input/filtered.json'); - expect(parsed.tracks[4].confidencePairs).toEqual([['leaf', 0.8]]); + expect(parsed.tracks[4].confidencePairs).toEqual([['root', 0.2]]); }); }); diff --git a/client/platform/desktop/backend/serializers/dive.spec.ts b/client/platform/desktop/backend/serializers/dive.spec.ts new file mode 100644 index 000000000..dbf7aef4c --- /dev/null +++ b/client/platform/desktop/backend/serializers/dive.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { AnnotationsCurrentVersion, JsonConfig } from 'platform/desktop/constants'; +import { AnnotationSchema } from 'dive-common/apispec'; +import { filterTracks } from './dive'; + +describe('DIVE JSON serializer', () => { + it('clones kept tracks with threshold- and type-pruned raw confidence pairs', () => { + const data: AnnotationSchema = { + version: AnnotationsCurrentVersion, + groups: {}, + tracks: { + 1: { + id: 1, + begin: 0, + end: 0, + attributes: {}, + confidencePairs: [['fish', 0.9], ['shark', 0.2], ['whale', 0.8], ['zero', 0]], + features: [{ frame: 0, bounds: [0, 0, 1, 1] }], + }, + }, + }; + const original = data.tracks[1].confidencePairs.map(([name, score]) => [name, score]); + const meta = { + confidenceFilters: { default: 0.1, fish: 0.95, zero: 0 }, + } as unknown as JsonConfig; + + const filtered = filterTracks(data, meta, new Set(['fish', 'whale', 'zero']), { + excludeBelowThreshold: true, + header: true, + }); + + expect(filtered).not.toBe(data); + expect(filtered.tracks[1]).not.toBe(data.tracks[1]); + expect(filtered.tracks[1].confidencePairs).toEqual([['whale', 0.8], ['zero', 0]]); + expect(data.tracks[1].confidencePairs).toEqual(original); + }); +}); diff --git a/client/platform/desktop/backend/serializers/dive.ts b/client/platform/desktop/backend/serializers/dive.ts index a5a22d90e..3cf612068 100644 --- a/client/platform/desktop/backend/serializers/dive.ts +++ b/client/platform/desktop/backend/serializers/dive.ts @@ -50,7 +50,7 @@ function filterTracks( header: true, }, ): AnnotationSchema { - const filteredTracks = Object.values(data.tracks).filter((track) => { + const filteredTracks = Object.values(data.tracks).flatMap((track) => { const filters = meta.confidenceFilters || {}; /* Include only the pairs that exceed the threshold in CSV output */ const confidencePairs = options.excludeBelowThreshold @@ -59,7 +59,13 @@ function filterTracks( const filteredPairs = typeFilter.size > 0 ? confidencePairs.filter((x) => typeFilter.has(x[0])) : confidencePairs; - return filteredPairs.length > 0; + if (!filteredPairs.length) { + return []; + } + return [{ + ...track, + confidencePairs: filteredPairs.map(([name, confidence]) => [name, confidence] as [string, number]), + }]; }); // Convert the track list back into an object const updatedFilteredTracks: Record = {}; diff --git a/client/platform/desktop/backend/serializers/viame.spec.ts b/client/platform/desktop/backend/serializers/viame.spec.ts index 36499b0f7..5ed6db40f 100644 --- a/client/platform/desktop/backend/serializers/viame.spec.ts +++ b/client/platform/desktop/backend/serializers/viame.spec.ts @@ -313,6 +313,23 @@ describe('VIAME serialize testing', () => { const expectedOutput = ['first_type', '0.9', 'second_type', '0.7']; expect(checkConfidenceOutput(output)).toEqual(expectedOutput); }); + it('keeps an explicit zero score when its type threshold is zero', async () => { + const path = '/home/zero-threshold.csv'; + const stream = fs.createWriteStream(path); + const zeroData = JSON.parse(JSON.stringify(data)) as AnnotationSchema; + const [zeroTrack] = Object.values(zeroData.tracks); + zeroTrack.confidencePairs.push(['zero_type', 0]); + await serialize(stream, zeroData, { + ...meta, + confidenceFilters: { default: 0.65, zero_type: 0 }, + } as JsonConfig, new Set(), { + excludeBelowThreshold: true, + header: true, + }); + const output = fs.readFileSync(path).toString().split('\n'); + expect(checkConfidenceOutput(output)).toContain('zero_type'); + expect(checkConfidenceOutput(output)).toContain('0'); + }); }); // Returns the entries of the `# metadata` row (without the leading marker), or null if absent diff --git a/client/platform/desktop/frontend/components/Export.vue b/client/platform/desktop/frontend/components/Export.vue index 7d14c6b1c..363dfe5a0 100644 --- a/client/platform/desktop/frontend/components/Export.vue +++ b/client/platform/desktop/frontend/components/Export.vue @@ -330,7 +330,7 @@ export default defineComponent({ v-model="data.excludeUncheckedTypes" label="export checked types only" dense - hint="Export only the track types currently enabled in the type filter" + hint="Export only stored confidence pairs whose raw type names are checked; other pairs are removed from exported tracks" persistent-hint class="pt-0" /> diff --git a/client/platform/web-girder/views/Export.vue b/client/platform/web-girder/views/Export.vue index 32518ec29..4e318cae0 100644 --- a/client/platform/web-girder/views/Export.vue +++ b/client/platform/web-girder/views/Export.vue @@ -433,7 +433,7 @@ export default defineComponent({ v-model="excludeUncheckedTypes" label="export checked types only" dense - hint="Export only the track types currently enabled in the type filter" + hint="Export only stored confidence pairs whose raw type names are checked; other pairs are removed from exported tracks" persistent-hint class="pt-0" /> diff --git a/client/src/AttributeTrackFilterControls.ts b/client/src/AttributeTrackFilterControls.ts index 45b7e5296..233609a9b 100644 --- a/client/src/AttributeTrackFilterControls.ts +++ b/client/src/AttributeTrackFilterControls.ts @@ -112,6 +112,7 @@ export const trackIdPassesFilter = ( filters: AttributeTrackFilter[], userDefinedvals: userDefinedVals[], enabled: boolean[], + displayType: string | undefined, ) => { const track = getTrack(id); const trackAttributes = track.attributes; @@ -132,16 +133,22 @@ export const trackIdPassesFilter = ( }); for (let i = 0; i < trackFilters.length; i += 1) { const filter = trackFilters[i]; - // If we have a type filter only filter by the types specified - if (filter.typeFilter.length > 0 && !filter.typeFilter.includes(track.getType()[0])) { - return true; - } - if (trackAttributes[filter.attribute] === undefined && !filter.ignoreUndefined) { - return false; - } - const result = checkAttributes(filter.filter, trackAttributes[filter.attribute] as userDefinedVals, trackUserVals[i]); - if (!result) { - return false; + // Attribute type filters apply to the type the UI resolved for display, not + // necessarily confidencePairs[0] (which can be a hierarchy ancestor). + const appliesToDisplayType = filter.typeFilter.length === 0 + || filter.typeFilter.includes(displayType || ''); + if (appliesToDisplayType) { + if (trackAttributes[filter.attribute] === undefined && !filter.ignoreUndefined) { + return false; + } + const result = checkAttributes( + filter.filter, + trackAttributes[filter.attribute] as userDefinedVals, + trackUserVals[i], + ); + if (!result) { + return false; + } } } for (let i = 0; i < detectionFilters.length; i += 1) { @@ -149,7 +156,9 @@ export const trackIdPassesFilter = ( const index = track.featureIndex[k]; const detectionAttributes = track.features[index].attributes; const filter = detectionFilters[i]; - if (detectionAttributes) { + const appliesToDisplayType = filter.typeFilter.length === 0 + || filter.typeFilter.includes(displayType || ''); + if (detectionAttributes && appliesToDisplayType) { if (detectionAttributes[filter.attribute] === undefined && !filter.ignoreUndefined) { return false; } diff --git a/client/src/BaseAnnotation.ts b/client/src/BaseAnnotation.ts index 92f0e4295..07616d452 100644 --- a/client/src/BaseAnnotation.ts +++ b/client/src/BaseAnnotation.ts @@ -180,7 +180,7 @@ export default abstract class BaseAnnotation { * Figure out if any confidence pairs are above any corresponding thresholds */ static exceedsThreshold(pairs: Array, thresholds: Record): Array { - const defaultThresh = thresholds.default || 0; - return pairs.filter(([name, value]) => value >= (thresholds[name] || defaultThresh)); + const defaultThresh = thresholds.default ?? 0; + return pairs.filter(([name, value]) => value >= (thresholds[name] ?? defaultThresh)); } } diff --git a/client/src/TrackFilterControls.spec.ts b/client/src/TrackFilterControls.spec.ts index 5b7a0829a..c7bcde84f 100644 --- a/client/src/TrackFilterControls.spec.ts +++ b/client/src/TrackFilterControls.spec.ts @@ -4,11 +4,13 @@ import Track, { Feature } from './track'; import TrackFilterControls from './TrackFilterControls'; import GroupFilterControls from './GroupFilterControls'; import type { MarkChangesPendingFilter } from './BaseFilterControls'; +import Group from './Group'; import CameraStore from './CameraStore'; import { AnnotationId } from './BaseAnnotation'; import useSave from '../dive-common/use/useSave'; import { clientSettings } from '../dive-common/store/settings'; import { TypeHierarchyError } from '../dive-common/typeHierarchy'; +import { AttributeTrackFilter } from './AttributeTrackFilterControls'; const apiMocks = vi.hoisted(() => ({ saveConfig: vi.fn(), @@ -71,9 +73,9 @@ function makeGroupFilterControls(store: CameraStore) { confidenceVal?: number, currentType?: string, ) => { - store.setTrackType(id, newType, confidenceVal, currentType); + store.setGroupType(id, newType, confidenceVal, currentType); }; - const removeTypes = (id: AnnotationId, types: string[]) => store.removeTypes(id, types); + const removeTypes = (id: AnnotationId, types: string[]) => store.removeGroupTypes(id, types); const remove = (id: AnnotationId) => { store.removeGroups(id); }; @@ -109,7 +111,6 @@ function makeTrackFilterControls(markPending: MarkChangesPendingFilter = markCha markChangesPending: markPending, groupFilterControls, lookupGroups: cameraStore.lookupGroups, - getTrack: (track: AnnotationId, camera = 'singleCam') => (cameraStore.getTrack(track, camera)), getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track), renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) @@ -136,7 +137,6 @@ function makePairFixture( markChangesPending: markPending, groupFilterControls, lookupGroups: cameraStore.lookupGroups, - getTrack: (id, camera = 'singleCam') => cameraStore.getTrack(id, camera), getTracks: (id) => cameraStore.getTrackAll(id), renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) @@ -162,6 +162,33 @@ describe('useAnnotationFilters', () => { clientSettings.typeSettings.preventCascadeTypes = false; }); + it('deletes multicamera groups without mutating a colliding track id', () => { + const cameraStore = new CameraStore({ markChangesPending }); + cameraStore.removeCamera('singleCam'); + cameraStore.addCamera('left'); + cameraStore.addCamera('right'); + cameraStore.camMap.value.forEach(({ trackStore, groupStore }) => { + trackStore.insert(new Track(3, { + confidencePairs: [['track-type', 1]], + features, + }), { imported: true }); + groupStore.insert(new Group(3, { + confidencePairs: [['group-type', 1]], + members: {}, + }), { imported: true }); + groupStore.setEnableSorting(); + }); + const filters = makeGroupFilterControls(cameraStore); + filters.checkedTypes.value = ['group-type']; + + filters.removeTypeAnnotations(['group-type']); + + cameraStore.camMap.value.forEach(({ trackStore, groupStore }) => { + expect(trackStore.get(3).confidencePairs).toEqual([['track-type', 1]]); + expect(groupStore.getPossible(3)).toBeUndefined(); + }); + }); + it('loads absent and valid hierarchy state without creating a save instruction', () => { const tf = makeTrackFilterControls(); tf.setTypeHierarchy(undefined); @@ -490,6 +517,139 @@ describe('useAnnotationFilters', () => { expect(withoutPrevent).toBe(1); }); + it('keeps empty flat-mode annotations when Prevent Cascade is enabled', () => { + const { filters } = makePairFixture([[]]); + clientSettings.typeSettings.preventCascadeTypes = true; + + expect(filters.filteredAnnotations.value.map(({ annotation, context }) => ({ + id: annotation.id, + confidencePairIndex: context.confidencePairIndex, + }))).toEqual([{ id: 0, confidencePairIndex: -1 }]); + }); + + it.each(['track', 'detection'] as const)( + 'applies %s attribute type filters to the resolved hierarchy display type', + (type) => { + const { cameraStore, filters } = makePairFixture([ + [['root', 0.9], ['leaf', 0.8]], + ]); + const track = cameraStore.getTrack(0); + if (type === 'track') { + track.attributes.quality = 'bad'; + } else { + track.features[0].attributes = { quality: 'bad' }; + } + const attributeFilter: AttributeTrackFilter = { + name: `${type} quality`, + type, + typeFilter: ['leaf'], + attribute: 'quality', + filter: { op: '=', val: 'good' }, + enabled: true, + }; + filters.setTypeHierarchy({ leaf: 'root' }); + filters.setConfidenceFilters({ default: 0.5 }); + filters.loadTrackAttributesFilter([attributeFilter]); + + // The leaf is selected despite root's greater score, so the failing + // leaf-only attribute filter applies and excludes the track. + expect(filters.filteredAnnotations.value).toEqual([]); + + // Hiding leaf changes the resolved display type to root. The leaf-only + // filter is skipped for both track- and detection-scoped attributes. + filters.checkedTypes.value = ['root']; + expect(filters.filteredAnnotations.value.map(({ annotation }) => annotation.id)).toEqual([0]); + }, + ); + + it('applies each track attribute filter independently on a flat dataset', () => { + const { cameraStore, filters } = makePairFixture([[['fish', 0.9]]]); + const track = cameraStore.getTrack(0); + track.attributes.quality = 'bad'; + filters.loadTrackAttributesFilter([ + { + name: 'other-type quality', + type: 'track', + typeFilter: ['bird'], + attribute: 'quality', + filter: { op: '=', val: 'good' }, + enabled: true, + }, + { + name: 'fish quality', + type: 'track', + typeFilter: ['fish'], + attribute: 'quality', + filter: { op: '=', val: 'good' }, + enabled: true, + }, + ]); + + // The non-matching first filter is skipped rather than passing the whole track, + // so the matching second filter still excludes it. + expect(filters.filteredAnnotations.value).toEqual([]); + }); + + it('skips a non-matching detection attribute type filter on a flat dataset', () => { + const { cameraStore, filters } = makePairFixture([[['fish', 0.9]]]); + const track = cameraStore.getTrack(0); + track.features[0].attributes = { quality: 'bad' }; + filters.loadTrackAttributesFilter([{ + name: 'other-type quality', + type: 'detection', + typeFilter: ['bird'], + attribute: 'quality', + filter: { op: '=', val: 'good' }, + enabled: true, + }]); + + expect(filters.filteredAnnotations.value.map(({ annotation }) => annotation.id)).toEqual([0]); + }); + + it('uses the first configured camera for multicamera attribute filters', () => { + const cameraStore = new CameraStore({ markChangesPending }); + cameraStore.removeCamera('singleCam'); + cameraStore.addCamera('left'); + cameraStore.addCamera('right'); + cameraStore.camMap.value.get('left')?.trackStore.insert(new Track(8, { + confidencePairs: [['fish', 1]], + attributes: { quality: 'good' }, + features, + }), { imported: true }); + cameraStore.camMap.value.get('right')?.trackStore.insert(new Track(8, { + confidencePairs: [['fish', 1]], + attributes: { quality: 'bad' }, + features, + }), { imported: true }); + cameraStore.camMap.value.forEach(({ trackStore }) => trackStore.setEnableSorting()); + const groupFilters = makeGroupFilterControls(cameraStore); + const filters = new TrackFilterControls({ + sorted: cameraStore.sortedTracks, + remove: (id) => cameraStore.removeTracks(id), + markChangesPending, + groupFilterControls: groupFilters, + lookupGroups: cameraStore.lookupGroups, + getTracks: (id) => cameraStore.getTrackAll(id), + renameTrackPair: (id, currentType, newType) => ( + cameraStore.renameTrackPair(id, currentType, newType) + ), + setType: (id, type, confidence, current) => ( + cameraStore.setTrackType(id, type, confidence, current) + ), + removeTypes: (id, types) => cameraStore.removeTypes(id, types), + }); + filters.loadTrackAttributesFilter([{ + name: 'quality', + type: 'track', + typeFilter: ['fish'], + attribute: 'quality', + filter: { op: '=', val: 'good' }, + enabled: true, + }]); + + expect(filters.filteredAnnotations.value.map(({ annotation }) => annotation.id)).toEqual([8]); + }); + it('uses pair zero for the active-hierarchy disabled-filter bypass', () => { const { cameraStore, filters } = makePairFixture([[['root', 0.1], ['leaf', 0.9]]]); filters.setTypeHierarchy({ leaf: 'root' }); diff --git a/client/src/TrackFilterControls.ts b/client/src/TrackFilterControls.ts index 95f725053..cb64ec7a2 100644 --- a/client/src/TrackFilterControls.ts +++ b/client/src/TrackFilterControls.ts @@ -4,6 +4,7 @@ import { clientSettings } from 'dive-common/store/settings'; import { compileHierarchy, normalizeTypeHierarchy, + selectFlatPairIndex, rewriteHierarchyType, selectPairIndex, TypeHierarchy, @@ -22,7 +23,6 @@ export interface TypeHierarchySavePatch { interface TrackFilterControlsParams extends FilterControlsParams { lookupGroups: (annotationId: AnnotationId) => Group[]; - getTrack: (annotationId: AnnotationId, camera?: string) => Track; groupFilterControls: BaseFilterControls; getTracks: (annotationId: AnnotationId) => Track[]; renameTrackPair: ( @@ -125,29 +125,12 @@ export default class TrackFilterControls extends BaseFilterControls { -1, ); } else { - confidencePairIndex = annotation.confidencePairs - .findIndex(([confkey, confval]) => { - const confidenceThresh = Math.max( - confidenceFiltersVal[confkey] || 0, - confidenceFiltersVal.default, - ); - return confval >= confidenceThresh && checkedSet.has(confkey); - }); - if (clientSettings.typeSettings.preventCascadeTypes) { - const [confkey, confval] = annotation.confidencePairs[0]; - const confidenceThresh = Math.max( - confidenceFiltersVal[confkey] || 0, - confidenceFiltersVal.default, - ); - if (checkedSet.has(confkey) && confval > confidenceThresh) { - confidencePairIndex = 0; - } else { - confidencePairIndex = -1; - } - } - if (this.disableAnnotationFilters.value) { - confidencePairIndex = 0; - } + confidencePairIndex = selectFlatPairIndex(annotation.confidencePairs, { + checkedSet, + confidenceFilters: confidenceFiltersVal, + filtersDisabled: this.disableAnnotationFilters.value, + preventCascade: clientSettings.typeSettings.preventCascadeTypes ?? false, + }); } /* include annotations where at least 1 confidence pair is above * the threshold and part of the checked type set */ @@ -157,15 +140,21 @@ export default class TrackFilterControls extends BaseFilterControls { && enabledInGroupFilters && !resultsIds.has(annotation.id) ) { let addValue = true; - if (!this.disableAnnotationFilters.value && this.attributeFilters.value.length > 0 && params.getTrack !== undefined + if (!this.disableAnnotationFilters.value && this.attributeFilters.value.length > 0 && this.enabledFilters.value.length > 0) { - addValue = trackIdPassesFilter( - annotation.id, - params.getTrack as (trackId: AnnotationId) => Track, - this.attributeFilters.value, - this.userDefinedValues.value, - this.enabledFilters.value, - ); + const [canonicalTrack] = params.getTracks(annotation.id); + if (canonicalTrack === undefined) { + addValue = false; + } else { + addValue = trackIdPassesFilter( + annotation.id, + () => canonicalTrack, + this.attributeFilters.value, + this.userDefinedValues.value, + this.enabledFilters.value, + annotation.confidencePairs[confidencePairIndex]?.[0], + ); + } } if (addValue) { resultsIds.add(annotation.id); diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue index 9740e9a26..343bdd59e 100644 --- a/client/src/components/FilterList.vue +++ b/client/src/components/FilterList.vue @@ -15,10 +15,13 @@ import TooltipBtn from './TooltipButton.vue'; import TypeEditor from './TypeEditor.vue'; import TypeItem from './TypeItem.vue'; import BaseFilterControls from '../BaseFilterControls'; +import TrackFilterControls from '../TrackFilterControls'; import Track from '../track'; import Group from '../Group'; import StyleManager from '../StyleManager'; -import { getSuppressedTrackIds, hasSuppressionAttribute } from '../use/suppression'; +import { + getSuppressedTrackIds, hasSuppressionAttribute, suppressionTypeResolver, +} from '../use/suppression'; interface VirtualTypeItem { type: string; @@ -97,6 +100,9 @@ export default defineComponent({ filterText: '', }); const trackFilters = props.filterControls; + const suppressionResolutionRef = computed(() => ( + trackFilters instanceof TrackFilterControls ? suppressionTypeResolver(trackFilters) : undefined + )); const checkedTypesRef = trackFilters.checkedTypes; const allTypesRef = trackFilters.allTypes; const usedTypesRef = trackFilters.usedTypes; @@ -161,6 +167,7 @@ export default defineComponent({ const editRevision = pendingSaveCount.value; const suppType = clientSettings.typeSettings.suppressionType; const suppThreshold = clientSettings.typeSettings.suppressionThreshold; + const suppressionResolver = suppressionResolutionRef.value; const excluded = new Set(); if (!suppType || editRevision < 0) { fullySuppressedIds.value = excluded; @@ -173,7 +180,9 @@ export default defineComponent({ const regionRanges: [number, number][] = []; store.annotationMap.forEach((annotation) => { const track = annotation as Track; - if (track.confidencePairs?.some(([t]) => t === suppType)) { + if ((suppressionResolver + ? suppressionResolver.displayType(track) + : track.confidencePairs?.[0]?.[0]) === suppType) { regionRanges.push([track.begin, track.end]); } }); @@ -183,7 +192,7 @@ export default defineComponent({ f, suppType, suppThreshold, - { revision: editRevision }, + { revision: editRevision, resolver: suppressionResolver }, ); store.annotationMap.forEach((annotation) => { const track = annotation as Track; @@ -208,6 +217,7 @@ export default defineComponent({ () => clientSettings.typeSettings.suppressionType, () => clientSettings.typeSettings.suppressionThreshold, cameraStore.camMap, + suppressionResolutionRef, ], () => computeFullySuppressedIds(), { immediate: true }, @@ -248,7 +258,7 @@ export default defineComponent({ frame.value, suppType, clientSettings.typeSettings.suppressionThreshold, - { revision: editRevision }, + { revision: editRevision, resolver: suppressionResolutionRef.value }, ) : new Set(); const filteredKeyFrameTracks = filteredTracksRef.value.filter((track) => { diff --git a/client/src/components/LayerManager.spec.ts b/client/src/components/LayerManager.spec.ts index 619a85c05..c1d87881e 100644 --- a/client/src/components/LayerManager.spec.ts +++ b/client/src/components/LayerManager.spec.ts @@ -261,7 +261,6 @@ function makeMultiCamFixture( remove: () => undefined, markChangesPending: () => undefined, lookupGroups: cameraStore.lookupGroups.bind(cameraStore), - getTrack: (id: AnnotationId, camera = 'left') => cameraStore.getTrack(id, camera), getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id), renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index b464c19f5..0f3f94391 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -21,7 +21,9 @@ import TextLayer, { FormatTextRow } from '../layers/AnnotationLayers/TextLayer'; import AttributeLayer from '../layers/AnnotationLayers/AttributeLayer'; import AttributeBoxLayer from '../layers/AnnotationLayers/AttributeBoxLayer'; import type { AnnotationId } from '../BaseAnnotation'; -import { getSuppressedTrackIds, hasSuppressionAttribute } from '../use/suppression'; +import { + getSuppressedTrackIds, hasSuppressionAttribute, suppressionTypeResolver, +} from '../use/suppression'; import { VisibleAnnotationTypes } from '../layers'; import UILayer from '../layers/UILayers/UILayer'; import ToolTipWidget from '../layers/UILayers/ToolTipWidget.vue'; @@ -107,6 +109,7 @@ export default defineComponent({ } const trackFilters = useTrackFilters(); const enabledTracksRef = trackFilters.enabledAnnotations; + const suppressionResolutionRef = computed(() => suppressionTypeResolver(trackFilters)); const selectedTrackIdRef = useSelectedTrackId(); const multiSeletListRef = useMultiSelectList(); const editingModeRef = useEditingMode(); @@ -368,7 +371,7 @@ export default defineComponent({ frame, suppressionType, suppressionThreshold, - { revision: pendingSaveCount.value }, + { revision: pendingSaveCount.value, resolver: suppressionResolutionRef.value }, ) : new Set(); currentFrameIds.forEach( @@ -665,6 +668,7 @@ export default defineComponent({ // re-render when the suppression-region type or threshold is changed () => clientSettings.typeSettings.suppressionType, () => clientSettings.typeSettings.suppressionThreshold, + suppressionResolutionRef, // re-render when attributes/geometry change (e.g. suppression attribute toggle) pendingSaveCount, ], diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index d723add0c..cc3ec1e30 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -24,7 +24,7 @@ import { usePendingSaveCount, } from '../../provides'; import useVirtualScrollTo from '../../use/useVirtualScrollTo'; -import { getSuppressedTrackIds } from '../../use/suppression'; +import { getSuppressedTrackIds, suppressionTypeResolver } from '../../use/suppression'; import SideBarTrackListView from './sidebar/SideBarTrackListView.vue'; import BottomBarTrackListView from './bottombar/BottomBarTrackListView.vue'; @@ -112,6 +112,7 @@ export default defineComponent({ const pairIndex = trackFilters.hierarchyActive.value ? contextIndex : 0; return track.confidencePairs[pairIndex]?.[1] ?? 0; }; + const suppressionResolutionRef = computed(() => suppressionTypeResolver(trackFilters)); const filterDetectionsByFrame = ref(clientSettings.trackSettings.trackListSettings.filterDetectionsByFrame); watch( @@ -140,7 +141,7 @@ export default defineComponent({ frameRef.value, suppType, clientSettings.typeSettings.suppressionThreshold, - { revision: editRevision }, + { revision: editRevision, resolver: suppressionResolutionRef.value }, ) : new Set(); tracks = tracks.filter((track) => { diff --git a/client/src/provides.ts b/client/src/provides.ts index 5cefbb0d6..93546ebf4 100644 --- a/client/src/provides.ts +++ b/client/src/provides.ts @@ -377,7 +377,6 @@ function dummyState(): State { markChangesPending, groupFilterControls, lookupGroups: cameraStore.lookupGroups, - getTrack: (track: AnnotationId, camera = 'singleCam') => (cameraStore.getTrack(track, camera)), getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track), renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) diff --git a/client/src/track.spec.ts b/client/src/track.spec.ts index 5f7fbfced..f8f2d2052 100644 --- a/client/src/track.spec.ts +++ b/client/src/track.spec.ts @@ -362,6 +362,8 @@ describe('exceedsThreshold', () => { expect(Track.exceedsThreshold([], {})).toEqual([]); expect(Track.exceedsThreshold([['foo', 1]], {})).toEqual([['foo', 1]]); expect(Track.exceedsThreshold([['foo', 0]], {})).toEqual([['foo', 0]]); + expect(Track.exceedsThreshold([['foo', 0]], { default: 0.1, foo: 0 })) + .toEqual([['foo', 0]]); }); }); diff --git a/client/src/use/suppression.spec.ts b/client/src/use/suppression.spec.ts index 0f528fccf..018c0be26 100644 --- a/client/src/use/suppression.spec.ts +++ b/client/src/use/suppression.spec.ts @@ -1,8 +1,10 @@ /// import Track, { TrackData } from '../track'; +import { clientSettings } from '../../dive-common/store/settings'; import { isSuppressedAttributeValue, hasSuppressionAttribute, getSuppressedTrackIds, normalizeSuppressionThreshold, DEFAULT_SUPPRESSION_THRESHOLD, + suppressionTypeResolver, } from './suppression'; function makeTrack(overrides: Partial = {}): Track { @@ -190,6 +192,72 @@ describe('getSuppressedTrackIds', () => { expect(getSuppressedTrackIds(store, 0, 'Suppressed', undefined, { revision: 2 })) .toEqual(new Set()); }); + + it('matches regions by their resolved display type and invalidates cache on resolution changes', () => { + covered.setFeature({ + frame: 0, + bounds: [10, 10, 90, 90], + keyframe: true, + interpolate: false, + }); + const store = makeStore([region, covered]); + const hiddenResolver = { + cacheKey: 'leaf-is-displayed', + displayType: () => 'leaf', + }; + expect(getSuppressedTrackIds(store, 0, 'Suppressed', undefined, { + revision: 1, + resolver: hiddenResolver, + })).toEqual(new Set()); + + const suppressionResolver = { + cacheKey: 'suppression-is-displayed', + displayType: (track: Track) => (track.id === 1 ? 'Suppressed' : 'seal'), + }; + expect(getSuppressedTrackIds(store, 0, 'Suppressed', undefined, { + revision: 1, + resolver: suppressionResolver, + })).toEqual(new Set([2])); + }); + + it('builds a resolver from TrackFilterControls display state', () => { + const filters = { + hierarchyActive: { value: true }, + hierarchyIndex: { value: { hierarchy: { leaf: 'root' } } }, + checkedTypes: { value: ['leaf'] }, + confidenceFilters: { value: { default: 0.1 } }, + disableAnnotationFilters: { value: false }, + displayPairIndex: () => 1, + }; + const resolver = suppressionTypeResolver(filters as never); + const track = makeTrack({ confidencePairs: [['root', 0.9], ['leaf', 0.8]] }); + expect(resolver.displayType(track)).toBe('leaf'); + expect(resolver.cacheKey).toContain('leaf'); + }); + + it('uses the checked and threshold-passing display pair in flat mode', () => { + clientSettings.typeSettings.preventCascadeTypes = false; + const filters = { + hierarchyActive: { value: false }, + checkedTypes: { value: ['Suppressed'] }, + confidenceFilters: { value: { default: 0.5 } }, + disableAnnotationFilters: { value: false }, + }; + const track = makeTrack({ + confidencePairs: [['hidden', 0.9], ['Suppressed', 0.8]], + }); + + expect(suppressionTypeResolver(filters).displayType(track)).toBe('Suppressed'); + + filters.confidenceFilters.value.default = 0.85; + expect(suppressionTypeResolver(filters).displayType(track)).toBeUndefined(); + + filters.confidenceFilters.value.default = 0.5; + clientSettings.typeSettings.preventCascadeTypes = true; + expect(suppressionTypeResolver(filters).displayType(track)).toBeUndefined(); + expect(suppressionTypeResolver(filters).cacheKey).toContain('preventCascadeTypes'); + clientSettings.typeSettings.preventCascadeTypes = false; + }); }); describe('getSuppressedTrackIds threshold', () => { diff --git a/client/src/use/suppression.ts b/client/src/use/suppression.ts index b5add3c44..7070ba9d6 100644 --- a/client/src/use/suppression.ts +++ b/client/src/use/suppression.ts @@ -25,6 +25,8 @@ import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; import type BaseAnnotationStore from 'vue-media-annotator/BaseAnnotationStore'; import type Track from 'vue-media-annotator/track'; import type { Feature } from 'vue-media-annotator/track'; +import { clientSettings } from 'dive-common/store/settings'; +import { selectFlatPairIndex } from 'dive-common/typeHierarchy'; export const DEFAULT_SUPPRESSION_THRESHOLD = 0.99; @@ -208,12 +210,61 @@ interface SuppressionCacheEntry { revision: number; type: string; threshold: number; + resolutionKey: string; byFrame: Map>; } /** Per-store memo of frame results, valid for one edit revision. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const suppressionCache = new WeakMap(); +export interface SuppressionTypeResolver { + displayType: (track: Track) => string | undefined; + cacheKey: string; +} + +type SuppressionFilterState = { + checkedTypes?: { value: string[] }; + confidenceFilters?: { value: Record }; + disableAnnotationFilters?: { value: boolean }; + displayPairIndex?: (track: Track, flatFallbackIndex: number) => number; + hierarchyActive?: { value: boolean }; + hierarchyIndex?: { value: { hierarchy: unknown } | undefined }; +}; + +/** Match suppression regions against the same type that the track filter displays. */ +export function suppressionTypeResolver( + trackFilters: SuppressionFilterState, +): SuppressionTypeResolver { + const hierarchyActive = trackFilters.hierarchyActive?.value ?? false; + const checkedTypes = trackFilters.checkedTypes?.value ?? []; + const confidenceFilters = trackFilters.confidenceFilters?.value ?? { default: 0 }; + const filtersDisabled = trackFilters.disableAnnotationFilters?.value ?? false; + const preventCascadeTypes = clientSettings.typeSettings.preventCascadeTypes ?? false; + const cacheKey = JSON.stringify({ + hierarchyActive, + hierarchy: trackFilters.hierarchyIndex?.value?.hierarchy, + checkedTypes, + confidenceFilters, + disableAnnotationFilters: filtersDisabled, + preventCascadeTypes, + }); + const flatDisplayIndex = (track: Track): number => selectFlatPairIndex(track.confidencePairs, { + checkedSet: new Set(checkedTypes), + confidenceFilters, + filtersDisabled, + preventCascade: preventCascadeTypes, + }); + return { + cacheKey, + displayType: (track) => { + const flatIndex = flatDisplayIndex(track); + const index = hierarchyActive && trackFilters.displayPairIndex + ? trackFilters.displayPairIndex(track, flatIndex) : flatIndex; + return index >= 0 ? track.confidencePairs[index]?.[0] : undefined; + }, + }; +} + /** * Loose truthiness for a suppression attribute value set by a user or * pipeline: true, a nonzero number, or 'true'/'yes'/'on'/'1' (any case). @@ -261,10 +312,12 @@ export function getSuppressedTrackIds( frame: number, suppressionType: string | undefined, thresholdPercent?: number, - options: { revision?: number } = {}, + options: { revision?: number; resolver?: SuppressionTypeResolver } = {}, ): Set { if (!suppressionType) return new Set(); const threshold = normalizeSuppressionThreshold(thresholdPercent); + const { resolver } = options; + const resolutionKey = resolver?.cacheKey || ''; let cacheEntry: SuppressionCacheEntry | undefined; const { revision } = options; @@ -272,9 +325,10 @@ export function getSuppressedTrackIds( cacheEntry = suppressionCache.get(trackStore); if (!cacheEntry || cacheEntry.revision !== revision || cacheEntry.type !== suppressionType - || cacheEntry.threshold !== threshold) { + || cacheEntry.threshold !== threshold + || cacheEntry.resolutionKey !== resolutionKey) { cacheEntry = { - revision, type: suppressionType, threshold, byFrame: new Map(), + revision, type: suppressionType, threshold, resolutionKey, byFrame: new Map(), }; suppressionCache.set(trackStore, cacheEntry); } @@ -297,7 +351,7 @@ export function getSuppressedTrackIds( if (!track) return; const shape = featureShape(track.getFeature(frame)[0]); if (!shape) return; - if (track.confidencePairs.some(([t]) => t === suppressionType)) { + if ((resolver ? resolver.displayType(track) : track.confidencePairs[0]?.[0]) === suppressionType) { regions.push(prepareRegion(shape)); } else { candidates.push({ id, shape }); diff --git a/docs/DataFormats.md b/docs/DataFormats.md index 44789cfe1..e6b3ed540 100644 --- a/docs/DataFormats.md +++ b/docs/DataFormats.md @@ -341,6 +341,10 @@ DIVE Web and Desktop can import and export COCO for a single dataset at a time (an image-sequence dataset or a single video dataset). KWCOCO-compatible files are also accepted on import. +When **Checked Types Only** is enabled, export matches the checked names against each track's raw +stored confidence pairs and removes nonmatching pairs from the exported vector. It does not replace +that evidence with the hierarchy-resolved type currently displayed in the viewer. + * Read the [COCO Specification](https://cocodataset.org/#format-data) * Read the [KWCOCO Specification](https://kwcoco.readthedocs.io/en/release/getting_started.html) diff --git a/docs/UI-Navigation-Editing-Bar.md b/docs/UI-Navigation-Editing-Bar.md index b9e556e7c..6aa3afac3 100644 --- a/docs/UI-Navigation-Editing-Bar.md +++ b/docs/UI-Navigation-Editing-Bar.md @@ -12,7 +12,9 @@ The navigation bar is the row of controls at the very top of the window. * overwrite the style and attribute configuration with a config `.json` file. * ==:material-download: Download== (Web) or ==:material-application-export: Export== (Desktop) allows for exporting all or part of the current dataset. * **Exclude Tracks** - this allows you to remove tracks below a specific confidence threshold when exporting the CSV. It is how you can export only the higher detections/tracks after running a pipeline. - * **Checked Types Only** - allows you to only export the annotations of types that are currently checked in the type list. + * **Checked Types Only** - filters stored confidence pairs by their raw type names and removes + unchecked pairs from exported tracks. This export boundary intentionally does not substitute + the hierarchy-resolved display type. * **Web-specific options** are documented in the [web download section](Web-Version.md#download-or-export-data) * ==:material-content-copy: Clone== is documented in the [web clone section](Web-Version.md#dataset-clones). * ==:material-help-circle: Help== provides mouse/keyboard shortcuts as well as a link to this documentation. diff --git a/docs/UI-Suppression.md b/docs/UI-Suppression.md index ef01b17f5..275346e35 100644 --- a/docs/UI-Suppression.md +++ b/docs/UI-Suppression.md @@ -6,7 +6,9 @@ There are two related mechanisms. Both require a **Suppression Region Type** to ## Region suppression -Draw or import annotations whose type matches the configured **Suppression Region Type**. On each frame, any other detection whose geometry lies at least the **Suppression Overlap (%)** under one or more of those regions is treated as region-suppressed: +Draw or import annotations whose hierarchy-resolved displayed type matches the configured +**Suppression Region Type**. On each frame, any other detection whose geometry lies at least the +**Suppression Overlap (%)** under one or more of those regions is treated as region-suppressed: * It is **hidden** from the annotation canvas. * It is **excluded** from type counts and from the track list for that frame. diff --git a/docs/UI-Type-List.md b/docs/UI-Type-List.md index 2b418f6e8..8e72b149a 100644 --- a/docs/UI-Type-List.md +++ b/docs/UI-Type-List.md @@ -22,7 +22,8 @@ The Type List is used to control visual styles of the different types as well as When a dataset has a type hierarchy, DIVE displays the deepest checked type whose confidence meets its threshold. For example, given `fish` → `shark` → `great white shark`, if `great white shark` falls below its threshold while `shark` passes, the track displays as `shark`. Confidence values do not need to decrease or increase monotonically through the hierarchy; DIVE selects from the qualifying pairs and preserves confidence-pair order between unrelated branches. -Track attribute filters are an exception: they match a track's raw top confidence pair, not the hierarchy-selected pair. +Type-specific track and detection attribute filters match this hierarchy-resolved displayed type, +so the type picker and the filter operate on the same classification identity. Assigning a type to a track is hierarchy-aware. A type together with its ancestors and descendants is one classification claim, so assigning over any pair in that lineage replaces the whole chain rather than leaving a relative behind for the display to select instead. Stored ancestors of the newly assigned type survive because the assignment still implies them, and pairs on unrelated branches are untouched. @@ -32,6 +33,11 @@ Track notes, track attributes, and first-detection attributes edited from a trac Linked multicamera tracks are expected to store identical confidence-pair vectors. If existing camera replicas differ, DIVE reports one warning when the dataset loads and uses the first camera in configured display order for the read-only track projection; it does not union classifications while merging display geometry. Removing classifications evaluates the complete logical vector and synchronizes the result across replicas. +The Web Library's dataset-label aggregation is intentionally different: it reports the raw +highest-confidence pair, keeping the first stored pair when scores tie, because the server does not +have each viewer's checked types and confidence thresholds. Viewer counts and filtering use the +hierarchy-resolved displayed type. + Hierarchy members remain ordinary flat Type List rows. A parent with no annotations or explicit style configuration is visible when **Show Empty** is enabled. The Type List does not render a tree or offer subtree controls or hierarchy editing. While a hierarchy is active, **Prevent Cascade Types** is disabled and shows: `Not applicable to hierarchical types; DIVE selects the deepest qualifying type.` Its saved value is preserved and becomes active again when the hierarchy is removed. diff --git a/server/dive_server/crud_annotation.py b/server/dive_server/crud_annotation.py index 68f7fdafa..e9753d7cc 100644 --- a/server/dive_server/crud_annotation.py +++ b/server/dive_server/crud_annotation.py @@ -394,7 +394,13 @@ def add_annotations( def get_labels(user: types.GirderUserModel, published=False, shared=False): - """Find all the labels in all datasets belonging to the user""" + """Find raw highest-score confidence-pair labels in datasets visible to ``user``. + + This aggregation intentionally does not resolve type hierarchies. Resolved display + types depend on each viewer's checked types and confidence thresholds, neither of + which the server has. The aggregation chooses the maximum raw score while keeping + the first stored pair when scores tie. + """ accessLevel = AccessType.WRITE if published or shared: accessLevel = AccessType.READ @@ -418,10 +424,42 @@ def get_labels(user: types.GirderUserModel, published=False, shared=False): {'$match': {'$expr': {'$eq': [{'$type': "$rev_deleted"}, 'missing']}}}, # Select the confidencePairs, which is the only field needed {'$project': {'confidencePairs': 1}}, - # Use the first confidence pair in the array, which assumes they are - # sorted in descending order - {'$set': {'confidencePairs': {'$first': '$confidencePairs'}}}, - {'$set': {'confidencePairs': {'$first': '$confidencePairs'}}}, + # Preserve the raw highest-score pair. Do not resolve hierarchy here: + # resolved display types are viewer-specific (checked types/thresholds). + # A strict comparison keeps the first stored pair when scores tie. + { + '$set': { + 'confidencePairs': { + '$reduce': { + 'input': '$confidencePairs', + 'initialValue': [], + 'in': { + '$cond': [ + { + '$or': [ + {'$eq': [{'$size': '$$value'}, 0]}, + { + '$gt': [ + {'$arrayElemAt': ['$$this', 1]}, + {'$arrayElemAt': ['$$value', 1]}, + ] + }, + ] + }, + '$$this', + '$$value', + ], + }, + }, + } + } + }, + # Reduce returns the winning [type, score] pair. Library labels are + # strings, so project the pair back to its raw type name before grouping. + {'$set': {'confidencePairs': {'$arrayElemAt': ['$confidencePairs', 0]}}}, + # Imported empty vectors have no raw label and must not create a null + # Library row. The public label API guarantees string identifiers. + {'$match': {'$expr': {'$eq': [{'$type': '$confidencePairs'}, 'string']}}}, ], }, }, diff --git a/server/tests/test_coco_export_filter.py b/server/tests/test_coco_export_filter.py index 692a22b13..a3ca64fcb 100644 --- a/server/tests/test_coco_export_filter.py +++ b/server/tests/test_coco_export_filter.py @@ -1,4 +1,5 @@ from copy import deepcopy +import json from dive_server import crud_annotation, crud_dataset @@ -29,3 +30,136 @@ def test_type_filter_prunes_exported_confidence_pairs_without_mutating_storage(m } } assert tracks['7']['confidencePairs'] == [['fish', 0.8], ['shark', 0.4]] + + +def test_type_filter_matches_raw_pair_membership_not_hierarchy_resolution(monkeypatch): + tracks = { + '7': { + 'id': 7, + 'begin': 0, + 'end': 0, + 'confidencePairs': [['fish', 0.8]], + 'attributes': {}, + 'features': [{'frame': 0, 'bounds': [1, 2, 3, 4]}], + } + } + monkeypatch.setattr( + crud_annotation, + 'get_annotations', + lambda _folder, revision=None: {'tracks': tracks}, + ) + folder = {'meta': {'typeHierarchy': {'salmon': 'fish'}}} + + assert crud_dataset._filtered_annotation_tracks(folder, None, False, {'fish'}) == { + '7': deepcopy(tracks['7']), + } + assert crud_dataset._filtered_annotation_tracks(folder, None, False, {'salmon'}) == {} + + +def test_export_filters_prune_threshold_and_type_pairs_without_mutating_storage(monkeypatch): + tracks = { + '7': { + 'id': 7, + 'begin': 0, + 'end': 0, + 'confidencePairs': [['fish', 0.8], ['shark', 0.4], ['ray', 0.9]], + 'attributes': {}, + 'features': [{'frame': 0, 'bounds': [1, 2, 3, 4]}], + } + } + monkeypatch.setattr( + crud_annotation, + 'get_annotations', + lambda _folder, revision=None: {'tracks': tracks}, + ) + folder = {'meta': {'confidenceFilters': {'default': 0.5, 'fish': 0.85}}} + + exported = crud_dataset._filtered_annotation_tracks(folder, None, True, {'fish', 'ray'}) + + assert exported['7']['confidencePairs'] == [['ray', 0.9]] + assert exported['7'] is not tracks['7'] + assert tracks['7']['confidencePairs'] == [['fish', 0.8], ['shark', 0.4], ['ray', 0.9]] + + +def test_full_archive_dive_json_prunes_filtered_pairs_without_mutating_storage(monkeypatch): + tracks = { + '7': { + 'id': 7, + 'begin': 0, + 'end': 0, + 'confidencePairs': [['fish', 0.8], ['shark', 0.4]], + 'attributes': {}, + 'features': [{'frame': 0, 'bounds': [1, 2, 3, 4]}], + } + } + monkeypatch.setattr( + crud_annotation, + 'get_annotations', + lambda _folder, revision=None: {'tracks': tracks}, + ) + monkeypatch.setattr(crud_dataset.crud, 'getCloneRoot', lambda _user, folder: folder) + + class Zip: + def addFile(self, maker, path): + if str(path).endswith('annotations.dive.json'): + for data in maker(): + yield data.encode() + + chunks = list( + crud_dataset._yield_single_dataset_export( + Zip(), + './dataset/', + {'name': 'dataset', 'meta': {}}, + {'_id': 'user'}, + includeMedia=False, + includeDetections=True, + excludeBelowThreshold=False, + typeFilter={'fish'}, + ) + ) + + exported = json.loads(next(chunk for chunk in chunks if b'confidencePairs' in chunk)) + assert exported['tracks']['7']['confidencePairs'] == [['fish', 0.8]] + assert tracks['7']['confidencePairs'] == [['fish', 0.8], ['shark', 0.4]] + + +def test_library_labels_use_raw_highest_score_and_exclude_empty_vectors(monkeypatch): + captured = {} + + class Collection: + def aggregate(self, pipeline): + captured['pipeline'] = pipeline + return ['raw-result'] + + class FolderModel: + collection = Collection() + + monkeypatch.setattr(crud_annotation, 'Folder', lambda: FolderModel()) + monkeypatch.setattr(crud_dataset, 'get_dataset_query', lambda *args, **kwargs: {}) + + assert crud_annotation.get_labels({'_id': 'user'}) == ['raw-result'] + lookup_pipeline = captured['pipeline'][1]['$lookup']['pipeline'] + reducer = lookup_pipeline[3]['$set']['confidencePairs']['$reduce'] + assert reducer['input'] == '$confidencePairs' + assert reducer['initialValue'] == [] + condition = reducer['in']['$cond'][0]['$or'] + assert condition[1] == { + '$gt': [ + {'$arrayElemAt': ['$$this', 1]}, + {'$arrayElemAt': ['$$value', 1]}, + ], + } + assert lookup_pipeline[4] == { + '$set': { + 'confidencePairs': { + '$arrayElemAt': ['$confidencePairs', 0], + }, + }, + } + assert lookup_pipeline[5] == { + '$match': { + '$expr': { + '$eq': [{'$type': '$confidencePairs'}, 'string'], + }, + }, + }