From 90917889d3d36038feae38e2675af45dd11a4cab Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 11 Aug 2026 17:59:53 -0400 Subject: [PATCH 1/4] Resolve attribute filters and suppression types --- client/dive-common/components/Viewer.vue | 1 - client/dive-common/typeHierarchy.spec.ts | 23 +++ client/dive-common/typeHierarchy.ts | 34 ++++ client/dive-common/use/useModeManager.spec.ts | 2 - client/src/AttributeTrackFilterControls.ts | 31 ++-- client/src/TrackFilterControls.spec.ts | 168 +++++++++++++++++- client/src/TrackFilterControls.ts | 53 +++--- client/src/components/FilterList.vue | 18 +- client/src/components/LayerManager.spec.ts | 1 - client/src/components/LayerManager.vue | 8 +- client/src/components/Tracks/TrackList.vue | 5 +- client/src/provides.ts | 1 - client/src/use/suppression.spec.ts | 68 +++++++ client/src/use/suppression.ts | 62 ++++++- docs/UI-Suppression.md | 4 +- 15 files changed, 414 insertions(+), 65 deletions(-) diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index ce0f906be..3c0eb9855 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -677,7 +677,6 @@ export default defineComponent({ remove: removeTracks, markChangesPending: (markChangesPending as MarkChangesPendingFilter), 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/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/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/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/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/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. From cf537ef325adb3fb0761fadb8d764014b7b689d1 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 11 Aug 2026 18:00:26 -0400 Subject: [PATCH 2/4] Name raw classification boundaries --- .../desktop/backend/serializers/coco.spec.ts | 9 +- .../desktop/backend/serializers/dive.spec.ts | 38 +++++ .../desktop/backend/serializers/dive.ts | 10 +- .../desktop/backend/serializers/viame.spec.ts | 17 +++ .../desktop/frontend/components/Export.vue | 2 +- client/platform/web-girder/views/Export.vue | 2 +- client/src/BaseAnnotation.ts | 4 +- client/src/track.spec.ts | 2 + docs/DataFormats.md | 4 + docs/UI-Navigation-Editing-Bar.md | 4 +- docs/UI-Type-List.md | 8 +- server/dive_server/crud_annotation.py | 48 ++++++- server/tests/test_coco_export_filter.py | 134 ++++++++++++++++++ 13 files changed, 265 insertions(+), 17 deletions(-) create mode 100644 client/platform/desktop/backend/serializers/dive.spec.ts 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/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/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/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-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'], + }, + }, + } From d5b62ab73370f02d5a33c3753cdd4014f42970a0 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 13 Aug 2026 18:24:02 -0400 Subject: [PATCH 3/4] Persist global definitions in multicamera datasets Attribute and filter definitions could be dropped after singleCam was replaced by named camera maps. Route global definitions through an available map so saving and reloading multicamera datasets preserves them. --- client/dive-common/use/useSave.ts | 8 ++++--- .../use/useSaveClassification.spec.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) 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: [], + }); + }); }); From 25e427be08ea890eb5b6e18b422bb7da57db01e9 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 13 Aug 2026 18:24:02 -0400 Subject: [PATCH 4/4] Hide bulk type updates outside multi-track editing The bulk update button remained available during ordinary track selection and could assign its hidden default. Show and execute the action only while multi-track editing is active. --- .../components/TrackDetailsPanel.spec.ts | 14 +++++++++++++- .../dive-common/components/TrackDetailsPanel.vue | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) 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({ />