diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index ded784c5d..57ba46287 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -256,6 +256,7 @@ type DatasetInfoFields = Record; * The parts of dataset config a user should be able to modify. */ interface DatasetConfigMutable { + typeHierarchy?: Record | null; customTypeStyling?: Record; customGroupStyling?: Record; confidenceFilters?: Record; @@ -271,7 +272,7 @@ interface DatasetConfigMutable { cameraRegistrationSource?: RegistrationSource | null; error?: string; } -const DatasetConfigMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo', 'cameraHomographies', 'cameraCorrespondences', 'cameraTransformTypes', 'cameraRegistrationSource']; +const DatasetConfigMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo', 'cameraHomographies', 'cameraCorrespondences', 'cameraTransformTypes', 'cameraRegistrationSource', 'typeHierarchy']; /** * Cross-dataset color/style overrides, reused across every dataset when the * "shared" color scope is enabled (see clientSettings.typeSettings.colorScope). diff --git a/client/dive-common/components/BottomPanel.vue b/client/dive-common/components/BottomPanel.vue index 638b31c02..aaf8bfcba 100644 --- a/client/dive-common/components/BottomPanel.vue +++ b/client/dive-common/components/BottomPanel.vue @@ -178,6 +178,7 @@ export default defineComponent({ diff --git a/client/dive-common/components/Sidebar.vue b/client/dive-common/components/Sidebar.vue index 3e37c0ebb..c8bc2aa2a 100644 --- a/client/dive-common/components/Sidebar.vue +++ b/client/dive-common/components/Sidebar.vue @@ -155,6 +155,7 @@ export default defineComponent({ readOnlyMode, styleManager, disableAnnotationFilters: trackFilterControls.disableAnnotationFilters, + hierarchyActive: trackFilterControls.hierarchyActive, confidenceFilters: trackFilterControls.confidenceFilters, visible, horizontalTabIcon, @@ -194,6 +195,7 @@ export default defineComponent({ @@ -396,6 +398,7 @@ export default defineComponent({ diff --git a/client/dive-common/components/TrackDetailsPanel.spec.ts b/client/dive-common/components/TrackDetailsPanel.spec.ts new file mode 100644 index 000000000..952a2d73a --- /dev/null +++ b/client/dive-common/components/TrackDetailsPanel.spec.ts @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +/// +import { defineComponent, h, ref } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Track from 'vue-media-annotator/track'; +import TrackDetailsPanel from './TrackDetailsPanel.vue'; + +const state = vi.hoisted(() => ({ + displayPairIndex: vi.fn(() => 1), + track: null as Track | null, +})); + +vi.mock('vue-media-annotator/provides', () => ({ + useSelectedTrackId: () => ref(1), + useEditingMode: () => ref(false), + useHandler: () => ({ + trackSelectNext: vi.fn(), + trackSplit: vi.fn(), + removeTrack: vi.fn(), + unstageFromMerge: vi.fn(), + setAttribute: vi.fn(), + deleteAttribute: vi.fn(), + removeGroup: vi.fn(), + toggleMerge: vi.fn(), + }), + useTrackFilters: () => ({ + allTypes: ref(['root', 'leaf']), + displayPairIndex: state.displayPairIndex, + }), + useAttributes: () => ref([]), + useMultiSelectList: () => ref([]), + useTime: () => ({ frame: ref(0) }), + useReadOnlyMode: () => ref(false), + useTrackStyleManager: () => ({ + typeStyling: ref({ color: (type: string) => `color:${type}` }), + }), + useEditingGroupId: () => ref(null), + useEditingMultiTrack: () => ref(false), + useGroupFilterControls: () => ({ allTypes: ref([]) }), + useCameraStore: () => ({ + camMap: ref(new Map([['singleCam', { groupStore: undefined }]])), + getAnyTrack: () => state.track, + getAnyPossibleTrack: () => state.track, + setTrackType: vi.fn(), + }), + useSelectedCamera: () => ref('singleCam'), +})); + +/** + * `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` + * SFC is not, so the panel is rendered from a host that captures the real instance. It is + * left unstubbed to keep shallow semantics for its own children. + */ +function mountPanel() { + let child: InstanceType | undefined; + const Host = defineComponent({ + setup: () => () => h(TrackDetailsPanel, { + props: { hotkeysDisabled: false }, + ref: (instance) => { + if (instance && !(instance instanceof Element)) { + child = instance as InstanceType; + } + }, + }), + }); + const wrapper = shallowMount(Host, { stubs: { TrackDetailsPanel: false } }); + if (!child) { + throw new Error('TrackDetailsPanel did not mount'); + } + return { wrapper, vm: child }; +} + +describe('TrackDetailsPanel hierarchy summary', () => { + beforeEach(() => { + state.displayPairIndex.mockReturnValue(1); + state.track = new Track(1, { + confidencePairs: [['root', 0.9], ['leaf', 0.7]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + }); + + it('passes the selected type, confidence index, and color to the summary TrackItem', () => { + const { wrapper } = mountPanel(); + const item = wrapper.findComponent({ name: 'TrackItem' }); + expect(item.exists()).toBe(true); + expect(item.props('trackType')).toBe('leaf'); + expect(item.props('displayPairIndex')).toBe(1); + expect(item.props('color')).toBe('color:leaf'); + }); + + it('falls back to the top pair when no pair passes the filters', () => { + state.displayPairIndex.mockReturnValue(-1); + const { wrapper } = mountPanel(); + const item = wrapper.findComponent({ name: 'TrackItem' }); + expect(item.exists()).toBe(true); + expect(item.props('trackType')).toBe('root'); + expect(item.props('displayPairIndex')).toBe(0); + expect(item.props('lockTypes')).toBe(false); + }); + + it('omits the header only for an empty confidence vector', () => { + state.track = new Track(1, { + confidencePairs: [], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + const { wrapper } = mountPanel(); + expect(wrapper.findComponent({ name: 'TrackItem' }).exists()).toBe(false); + }); +}); diff --git a/client/dive-common/components/TrackDetailsPanel.vue b/client/dive-common/components/TrackDetailsPanel.vue index 673a4be62..f5b40bd33 100644 --- a/client/dive-common/components/TrackDetailsPanel.vue +++ b/client/dive-common/components/TrackDetailsPanel.vue @@ -71,7 +71,8 @@ export default defineComponent({ const editingError: Ref = ref(null); const editingModeRef = useEditingMode(); const typeStylingRef = useTrackStyleManager().typeStyling; - const allTypesRef = useTrackFilters().allTypes; + const trackFilters = useTrackFilters(); + const allTypesRef = trackFilters.allTypes; const cameraStore = useCameraStore(); const multiCam = ref(cameraStore.camMap.value.size > 1); const selectedCamera = useSelectedCamera(); @@ -260,6 +261,19 @@ export default defineComponent({ cameraStore.setTrackType(track.id, type, 1, currentType); } + const displayRows = computed(() => selectedTrackList.value.map((track) => { + // trackFilters returns -1 when no confidence pair passes the filters, but this panel + // always shows the selected track, so clamp to pair 0. + const pairIndex = Math.max(trackFilters.displayPairIndex(track, 0), 0); + return { + track, + // Re-run when track confidence pairs change (see AttributesSubsection revision pattern) + revision: track.revision.value, + pairIndex, + pair: track.confidencePairs.length ? track.confidencePairs[pairIndex] : null, + }; + })); + return { selectedTrackIdRef, editingGroupIdRef, @@ -302,6 +316,8 @@ export default defineComponent({ updateSelectedTracksType, setTrackType, displayConfidencePairs, + displayRows, + trackFilters, }; }, }); @@ -423,7 +439,7 @@ export default defineComponent({ class="track-details" >
+import { + defineComponent, h, nextTick, reactive, +} from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { clientSettings } from 'dive-common/store/settings'; +import TypeSettingsPanel from './TypeSettingsPanel.vue'; + +interface PanelProps { + allTypes: string[]; + hierarchyActive: boolean; +} + +/** + * `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` + * SFC is not, so the panel is rendered from a host and left unstubbed to keep shallow + * semantics for its own children. The host also stands in for `setProps`. + */ +function mountPanel(props: PanelProps) { + const state = reactive(props); + const Host = defineComponent({ + setup: () => () => h(TypeSettingsPanel, { props: state }), + }); + const wrapper = shallowMount(Host, { stubs: { TypeSettingsPanel: false } }); + const setProps = async (next: Partial) => { + Object.assign(state, next); + await nextTick(); + }; + return { wrapper, setProps }; +} + +describe('TypeSettingsPanel hierarchy state', () => { + afterEach(() => { + clientSettings.typeSettings.preventCascadeTypes = false; + }); + + it('disables Prevent Cascade with exact help while preserving its saved value', async () => { + clientSettings.typeSettings.preventCascadeTypes = true; + const { wrapper, setProps } = mountPanel({ allTypes: ['fish'], hierarchyActive: true }); + const preventSwitch = () => wrapper.findAll('v-switch').wrappers.find( + (item) => item.attributes('label') === 'Prevent Cascade Types', + ); + + expect(preventSwitch()?.attributes('disabled')).toBe('true'); + expect(wrapper.text()).toContain( + 'Not applicable to hierarchical types; DIVE selects the deepest qualifying type.', + ); + expect(clientSettings.typeSettings.preventCascadeTypes).toBe(true); + + await setProps({ hierarchyActive: false }); + expect(preventSwitch()?.attributes('disabled')).toBeUndefined(); + expect(wrapper.text()).not.toContain('Not applicable to hierarchical types'); + expect(clientSettings.typeSettings.preventCascadeTypes).toBe(true); + + await setProps({ hierarchyActive: true }); + expect(preventSwitch()?.attributes('disabled')).toBe('true'); + expect(clientSettings.typeSettings.preventCascadeTypes).toBe(true); + }); + + it('leaves the other type settings enabled', () => { + const { wrapper } = mountPanel({ allTypes: [], hierarchyActive: true }); + const switches = wrapper.findAll('v-switch').wrappers; + ['Show Empty', 'Lock Types', 'Filter Types by Frame', 'Show Max Count Button'].forEach( + (label) => expect(switches.find((item) => item.attributes('label') === label) + ?.attributes('disabled')).toBeUndefined(), + ); + }); +}); diff --git a/client/dive-common/components/TypeSettingsPanel.vue b/client/dive-common/components/TypeSettingsPanel.vue index 5c3211bb6..f08eb5fd8 100644 --- a/client/dive-common/components/TypeSettingsPanel.vue +++ b/client/dive-common/components/TypeSettingsPanel.vue @@ -16,6 +16,10 @@ export default defineComponent({ type: Array as PropType>, required: true, }, + hierarchyActive: { + type: Boolean, + required: true, + }, }, setup(props, { emit }) { const itemHeight = 45; // in pixels @@ -204,7 +208,14 @@ export default defineComponent({ class="my-0 ml-1 pt-0" dense hide-details + :disabled="hierarchyActive" /> +
+ Not applicable to hierarchical types; DIVE selects the deepest qualifying type. +
(cameraStore.getTrack(track, camera)), + getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track), groupFilterControls: groupFilters, setType: setTrackType, removeTypes, @@ -933,16 +934,27 @@ export default defineComponent({ }); } } + const typeHierarchyPatch = trackFilters.typeHierarchySavePatch(); try { - await saveToServer({ - customTypeStyling: trackStyleManager.getTypeStyles(trackFilters.allTypes), + const { canonicalConfigPersisted } = await saveToServer({ + customTypeStyling: trackStyleManager.getTypeStyles( + trackFilters.usedPlusConfiguredTypes, + ), customGroupStyling: groupStyleManager.getTypeStyles(groupFilters.allTypes), confidenceFilters: trackFilters.confidenceFilters.value, timeFilters: trackFilters.timeFilters.value, imageEnhancements: imageEnhancements.value, + ...typeHierarchyPatch, // TODO Group confidence filters are not yet supported. }, saveSet); + if (canonicalConfigPersisted) { + trackFilters.markTypeHierarchyPersisted(typeHierarchyPatch); + } } catch (err) { + const saveResult = err as { canonicalConfigPersisted?: boolean }; + if (saveResult.canonicalConfigPersisted) { + trackFilters.markTypeHierarchyPersisted(typeHierarchyPatch); + } let text = 'Unable to Save Data'; const saveErr = err as { response?: { status?: number } }; if (saveErr.response && saveErr.response.status === 403) { @@ -1495,6 +1507,15 @@ export default defineComponent({ // Close and reset sideBar context.resetActive(); const meta = await loadConfig(datasetId.value); + trackFilters.setTypeHierarchy(meta.typeHierarchy); + const hierarchyWarning = trackFilters.consumeLoadWarning(); + if (hierarchyWarning) { + await prompt({ + title: 'Invalid Type Hierarchy', + text: hierarchyWarning, + positiveButton: 'OK', + }); + } baseMulticamDatasetId.value = datasetId.value; if (meta.multiCamMedia) { /* We're loading a multicamera dataset */ diff --git a/client/dive-common/typeHierarchy.spec.ts b/client/dive-common/typeHierarchy.spec.ts new file mode 100644 index 000000000..1a9778293 --- /dev/null +++ b/client/dive-common/typeHierarchy.spec.ts @@ -0,0 +1,151 @@ +import fs from 'fs-extra'; +import { + compileHierarchy, + normalizeTypeHierarchy, + resolveTypeHierarchy, + rewriteHierarchyType, + selectPairIndex, + TypeHierarchyError, +} from './typeHierarchy'; + +interface ErrorExpectation { + errorReason: string | null; + errorKind: 'malformed' | 'conflict' | null; +} + +interface NormalizationCase extends ErrorExpectation { + name: string; + input: unknown; + expected: Record | null; +} + +interface ResolutionCase extends ErrorExpectation { + name: string; + existing: unknown; + incomingPresent: boolean; + incoming?: unknown; + mode: 'save' | 'overwrite' | 'additive'; + expectedAction: 'none' | 'delete' | 'set' | null; + expected?: Record; +} + +interface RenameCase extends ErrorExpectation { + name: string; + hierarchy: Record; + currentType: string; + newType: string; + expected: Record | null; +} + +interface SelectionCase { + name: string; + hierarchy: Record; + pairs: [string, number][]; + passes: boolean[]; + expectedIndex: number; +} + +interface TypeHierarchyCorpus { + normalizationCases: NormalizationCase[]; + resolutionCases: ResolutionCase[]; + renameCases: RenameCase[]; + selectionCases: SelectionCase[]; +} + +const corpus = fs.readJSONSync('../testutils/typeHierarchy.spec.json') as TypeHierarchyCorpus; + +function expectHierarchyError( + callback: () => unknown, + expectedReason: string, + expectedKind: 'malformed' | 'conflict', +) { + try { + callback(); + throw new Error('Expected TypeHierarchyError'); + } catch (error) { + expect(error).toBeInstanceOf(TypeHierarchyError); + expect((error as TypeHierarchyError).reason).toBe(expectedReason); + expect((error as TypeHierarchyError).kind).toBe(expectedKind); + expect((error as TypeHierarchyError).message).toBe(expectedReason); + } +} + +describe('shared type hierarchy corpus', () => { + describe.each(corpus.normalizationCases)('normalization: $name', (testCase) => { + it('matches the shared result', () => { + if (testCase.errorReason !== null && testCase.errorKind !== null) { + expectHierarchyError( + () => normalizeTypeHierarchy(testCase.input), + testCase.errorReason, + testCase.errorKind, + ); + } else { + expect(normalizeTypeHierarchy(testCase.input)).toEqual(testCase.expected || undefined); + } + }); + }); + + describe.each(corpus.resolutionCases)('resolution: $name', (testCase) => { + it('matches the shared result', () => { + const resolve = () => resolveTypeHierarchy( + testCase.existing, + testCase.incomingPresent, + testCase.incoming, + testCase.mode, + ); + if (testCase.errorReason !== null && testCase.errorKind !== null) { + expectHierarchyError(resolve, testCase.errorReason, testCase.errorKind); + } else { + const write = resolve(); + expect(write.action).toBe(testCase.expectedAction); + if (write.action === 'set') { + expect(write.hierarchy).toEqual(testCase.expected); + } else { + expect('hierarchy' in write).toBe(false); + } + } + }); + }); + + describe.each(corpus.renameCases)('rename: $name', (testCase) => { + it('matches the shared result', () => { + const rewrite = () => rewriteHierarchyType( + testCase.hierarchy, + testCase.currentType, + testCase.newType, + ); + if (testCase.errorReason !== null && testCase.errorKind !== null) { + expectHierarchyError(rewrite, testCase.errorReason, testCase.errorKind); + } else { + expect(rewrite()).toEqual(testCase.expected || undefined); + } + }); + }); + + describe.each(corpus.selectionCases)('selection: $name', (testCase) => { + it('matches the shared result', () => { + const hierarchy = normalizeTypeHierarchy(testCase.hierarchy) || {}; + expect(selectPairIndex( + compileHierarchy(hierarchy), + testCase.pairs, + testCase.passes, + )).toBe(testCase.expectedIndex); + }); + }); +}); + +describe('type hierarchy index', () => { + const hierarchy = normalizeTypeHierarchy({ cod: 'fish', fish: 'animal' }) || {}; + const index = compileHierarchy(hierarchy); + + it('normalizes into a fresh map', () => { + const input = { cod: 'fish' }; + expect(normalizeTypeHierarchy(input)).not.toBe(input); + }); + + it('rejects pair/pass length mismatches', () => { + expect(() => selectPairIndex(index, [['cod', 0.9]], [])).toThrow( + 'passes and pairs must have the same length', + ); + }); +}); diff --git a/client/dive-common/typeHierarchy.ts b/client/dive-common/typeHierarchy.ts new file mode 100644 index 000000000..c6649ee81 --- /dev/null +++ b/client/dive-common/typeHierarchy.ts @@ -0,0 +1,285 @@ +export type TypeHierarchy = Readonly>; + +export type HierarchyWrite = + | { action: 'none' } + | { action: 'delete' } + | { action: 'set'; hierarchy: TypeHierarchy }; + +export class TypeHierarchyError extends Error { + readonly reason: string; + + readonly kind: 'malformed' | 'conflict'; + + constructor(reason: string, kind: 'malformed' | 'conflict' = 'malformed') { + super(reason); + this.name = 'TypeHierarchyError'; + this.reason = reason; + this.kind = kind; + } +} + +export interface TypeHierarchyIndex { + hierarchy: TypeHierarchy; + ancestors: Readonly>; +} + +// 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 { + const leftPoints = [...left].map((char) => char.codePointAt(0) as number); + const rightPoints = [...right].map((char) => char.codePointAt(0) as number); + const sharedLength = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < sharedLength; index += 1) { + if (leftPoints[index] !== rightPoints[index]) { + return leftPoints[index] - rightPoints[index]; + } + } + return leftPoints.length - rightPoints.length; +} + +function sortedNames(names: readonly string[]): string[] { + return [...names].sort(codePointCompare); +} + +function hasOwn(hierarchy: TypeHierarchy, type: string): boolean { + return Object.prototype.hasOwnProperty.call(hierarchy, type); +} + +// Python's str.strip() and JS's String.trim() disagree at the edges, and a name blank on one +// platform must be blank on the other. These are the code points only Python calls blank; +// trim() already covers the rest, U+FEFF included. +const PYTHON_ONLY_BLANKS = new Set([0x1c, 0x1d, 0x1e, 0x1f, 0x85]); + +function isBlankName(name: string): boolean { + return [...name].every((char) => char.trim().length === 0 + || PYTHON_ONLY_BLANKS.has(char.codePointAt(0) as number)); +} + +function cycleReason(hierarchy: TypeHierarchy): string | undefined { + const completed = new Set(); + const renderedCycles: string[] = []; + + sortedNames(Object.keys(hierarchy)).forEach((start) => { + if (completed.has(start)) { + return; + } + const path: string[] = []; + const positions = new Map(); + let current: string | undefined = start; + while (current !== undefined && hasOwn(hierarchy, current) + && !completed.has(current) && !positions.has(current)) { + positions.set(current, path.length); + path.push(current); + current = hierarchy[current]; + } + if (current !== undefined && positions.has(current)) { + const cycle = path.slice(positions.get(current) as number); + let smallestIndex = 0; + cycle.forEach((name, index) => { + if (codePointCompare(name, cycle[smallestIndex]) < 0) { + smallestIndex = index; + } + }); + const rotated = cycle.slice(smallestIndex).concat(cycle.slice(0, smallestIndex)); + renderedCycles.push([...rotated, rotated[0]].join(' -> ')); + } + path.forEach((name) => completed.add(name)); + }); + + if (renderedCycles.length === 0) { + return undefined; + } + renderedCycles.sort(codePointCompare); + return `cycle ${renderedCycles[0]}`; +} + +// Mirrors server/dive_utils/type_hierarchy.py so client saves and headless imports agree. +export function normalizeTypeHierarchy(value: unknown): TypeHierarchy | undefined { + if (value === null) { + return undefined; + } + if (typeof value !== 'object' || Array.isArray(value)) { + throw new TypeHierarchyError('expected an object'); + } + + const source = value as Record; + const keys = sortedNames(Object.keys(source)); + if (keys.length === 0) { + return undefined; + } + const entries: [string, string][] = []; + keys.forEach((child) => { + if (isBlankName(child)) { + throw new TypeHierarchyError('empty child'); + } + const parent = source[child]; + if (typeof parent !== 'string') { + throw new TypeHierarchyError(`parent for "${child}" must be a string`); + } + if (isBlankName(parent)) { + throw new TypeHierarchyError(`empty parent for "${child}"`); + } + if (child === parent) { + throw new TypeHierarchyError(`self edge "${child} -> ${parent}"`); + } + entries.push([child, parent]); + }); + + const normalized = Object.fromEntries(entries); + const reason = cycleReason(normalized); + if (reason !== undefined) { + throw new TypeHierarchyError(reason); + } + return normalized; +} + +function conflict(reason: string): TypeHierarchyError { + return new TypeHierarchyError(reason, 'conflict'); +} + +export function resolveTypeHierarchy( + existing: unknown, + incomingPresent: boolean, + incoming: unknown, + mode: 'save' | 'overwrite' | 'additive', +): HierarchyWrite { + if (!incomingPresent) { + return { action: 'none' }; + } + + const normalizedIncoming = normalizeTypeHierarchy(incoming); + if (normalizedIncoming === undefined) { + // Additive follows JSON merge semantics: an explicit null deletes, an empty map is a no-op. + if (mode === 'additive' && incoming !== null) { + return { action: 'none' }; + } + return { action: 'delete' }; + } + if (mode !== 'additive') { + return { action: 'set', hierarchy: normalizedIncoming }; + } + + let normalizedExisting: TypeHierarchy | undefined; + try { + normalizedExisting = normalizeTypeHierarchy(existing); + } catch (error) { + if (error instanceof TypeHierarchyError) { + throw conflict(error.reason); + } + throw error; + } + + const merged = new Map(normalizedExisting + ? Object.entries(normalizedExisting) + : []); + sortedNames(Object.keys(normalizedIncoming)).forEach((child) => { + const incomingParent = normalizedIncoming[child]; + if (merged.has(child) && merged.get(child) !== incomingParent) { + throw conflict( + `conflicting parents for "${child}": "${merged.get(child)}" and "${incomingParent}"`, + ); + } + merged.set(child, incomingParent); + }); + + try { + return { + action: 'set', + hierarchy: normalizeTypeHierarchy(Object.fromEntries(merged)) as TypeHierarchy, + }; + } catch (error) { + if (error instanceof TypeHierarchyError) { + throw conflict(error.reason); + } + throw error; + } +} + +export function compileHierarchy(hierarchy: TypeHierarchy): TypeHierarchyIndex { + const normalized = normalizeTypeHierarchy(hierarchy) || {}; + const members = new Set(); + Object.entries(normalized).forEach(([child, parent]) => { + members.add(child); + members.add(parent); + }); + + const ancestorEntries: [string, readonly string[]][] = []; + sortedNames([...members]).forEach((member) => { + const memberAncestors: string[] = []; + let current = member; + while (hasOwn(normalized, current)) { + const parent = normalized[current]; + memberAncestors.push(parent); + current = parent; + } + ancestorEntries.push([member, memberAncestors]); + }); + + return { hierarchy: normalized, ancestors: Object.fromEntries(ancestorEntries) }; +} + +function ancestorsOf(index: TypeHierarchyIndex, type: string): readonly string[] { + return Object.prototype.hasOwnProperty.call(index.ancestors, type) + ? index.ancestors[type] + : []; +} + +export function rewriteHierarchyType( + hierarchy: TypeHierarchy, + currentType: string, + newType: string, +): TypeHierarchy | undefined { + const normalized = normalizeTypeHierarchy(hierarchy); + if (normalized === undefined) { + return undefined; + } + + const rewritten = new Map(); + const addEdge = (child: string, parent: string) => { + if (rewritten.has(child) && rewritten.get(child) !== parent) { + throw conflict( + `conflicting parents for "${child}": "${rewritten.get(child)}" and "${parent}"`, + ); + } + rewritten.set(child, parent); + }; + + sortedNames(Object.keys(normalized)) + .filter((child) => child !== currentType) + .forEach((child) => { + const parent = normalized[child] === currentType ? newType : normalized[child]; + addEdge(child, parent); + }); + if (hasOwn(normalized, currentType)) { + addEdge(newType, normalized[currentType]); + } + + try { + return normalizeTypeHierarchy(Object.fromEntries(rewritten)); + } catch (error) { + if (error instanceof TypeHierarchyError) { + throw conflict(error.reason); + } + throw error; + } +} + +export function selectPairIndex( + index: TypeHierarchyIndex, + pairs: readonly (readonly [string, number])[], + passes: readonly boolean[], +): number { + if (passes.length !== pairs.length) { + throw new Error('passes and pairs must have the same length'); + } + + const passingAncestorTypes = new Set(); + pairs.forEach(([type], pairIndex) => { + if (passes[pairIndex]) { + ancestorsOf(index, type).forEach((ancestor) => passingAncestorTypes.add(ancestor)); + } + }); + return pairs.findIndex(([type], pairIndex) => ( + passes[pairIndex] && !passingAncestorTypes.has(type) + )); +} diff --git a/client/dive-common/use/useModeManager.spec.ts b/client/dive-common/use/useModeManager.spec.ts index b9cda22c6..798c86838 100644 --- a/client/dive-common/use/useModeManager.spec.ts +++ b/client/dive-common/use/useModeManager.spec.ts @@ -13,6 +13,7 @@ import { IDENTITY3 } from 'vue-media-annotator/alignedView/alignedView'; import type { Matrix3 } from 'vue-media-annotator/alignedView/homography'; import type { AggregateMediaController } from 'vue-media-annotator/components/annotators/mediaControllerType'; import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; +import Track from 'vue-media-annotator/track'; import { ROTATION_ATTRIBUTE_NAME } from 'vue-media-annotator/utils'; import useModeManager from './useModeManager'; @@ -58,6 +59,7 @@ function makeHarness() { markChangesPending: () => undefined, lookupGroups: cameraStore.lookupGroups.bind(cameraStore), getTrack: (id: AnnotationId, camera = 'singleCam') => cameraStore.getTrack(id, camera), + getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id), groupFilterControls, setType: () => undefined, removeTypes: () => [], @@ -151,6 +153,18 @@ describe('useModeManager aligned-view track mirroring', () => { expect(cameraStore.getTrack(trackId, 'left').features[0]?.bounds).toEqual([10, 20, 30, 40]); }); + it('mirrors the whole source vector onto a newly created counterpart', () => { + const { cameraStore, modeManager } = makeHarness(); + const trackId = modeManager.handler.trackAdd(); + const source = cameraStore.getTrack(trackId, 'left'); + source.setType('leaf', 0.8); + modeManager.handler.updateRectBounds(0, 0, [10, 20, 30, 40]); + + const mirrored = cameraStore.getTrack(trackId, 'right'); + expect(mirrored.confidencePairs).toEqual(source.confidencePairs); + expect(mirrored.confidencePairs).not.toBe(source.confidencePairs); + }); + it('does not mirror while the aligned view is suspended (registration picking)', () => { const { cameraStore, alignedView, modeManager } = makeHarness(); alignedView.setSuspended(true); @@ -182,6 +196,7 @@ function makeSingleCamHarness() { markChangesPending: () => undefined, lookupGroups: cameraStore.lookupGroups.bind(cameraStore), getTrack: (id: AnnotationId, camera = 'singleCam') => cameraStore.getTrack(id, camera), + getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id), groupFilterControls, setType: () => undefined, removeTypes: () => [], @@ -194,9 +209,44 @@ function makeSingleCamHarness() { readonlyState: ref(false), recipes: [], }); - return { cameraStore, modeManager }; + return { cameraStore, modeManager, trackFilterControls }; } +describe('useModeManager counterpart creation', () => { + it('copies the source confidence vector onto the counterpart camera track', () => { + const { cameraStore, modeManager } = makeHarness(); + cameraStore.camMap.value.get('left')?.trackStore.insert(new Track(9, { + confidencePairs: [['root', 0.9], ['leaf', 0.8]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + })); + modeManager.selectedCamera.value = 'right'; + modeManager.handler.trackAdd(9); + + const source = cameraStore.getTrack(9, 'left'); + const counterpart = cameraStore.getTrack(9, 'right'); + expect(counterpart.confidencePairs).toEqual([['root', 0.9], ['leaf', 0.8]]); + expect(counterpart.confidencePairs).not.toBe(source.confidencePairs); + expect(counterpart.confidencePairs[0]).not.toBe(source.confidencePairs[0]); + }); +}); + +describe('TrackFilterControls construction', () => { + it('provides complete stored-track enumeration for hierarchy renames', () => { + const { cameraStore, trackFilterControls } = makeSingleCamHarness(); + const trackStore = cameraStore.camMap.value.get('singleCam')?.trackStore; + trackStore?.insert(new Track(7, { + confidencePairs: [['leaf', 1], ['root', 0.8]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + })); + trackStore?.setEnableSorting(); + trackFilterControls.setTypeHierarchy({ leaf: 'root' }); + trackFilterControls.updateTypeName({ currentType: 'leaf', newType: 'fin' }); + expect(cameraStore.getTrack(7).confidencePairs).toEqual([ + ['fin', 1], ['root', 0.8], + ]); + }); +}); + describe('useModeManager polygon clip on box resize', () => { // Triangle that sticks past x=20; clipping to [0,0,20,40] leaves a non-box shape. const stickingOutPolygon = { diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 79fecd5e5..71707ec5c 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -1,7 +1,9 @@ import { computed, Ref, reactive, ref, onBeforeUnmount, toRef, } from 'vue'; -import { uniq, flatMapDeep, flattenDeep } from 'lodash'; +import { + cloneDeep, uniq, flatMapDeep, flattenDeep, +} from 'lodash'; import Track, { Feature, TrackId, TrackSupportedFeature } from 'vue-media-annotator/track'; import { RectBounds, @@ -523,11 +525,12 @@ export default function useModeManager({ handleEscapeMode(); const frame = selectedCameraFrame(); let trackType = trackSettings.value.newTrackSettings.type; + let sourceTrack: Track | undefined; if (overrideTrackId !== undefined) { - const track = cameraStore.getAnyPossibleTrack(overrideTrackId); - if (track !== undefined) { + sourceTrack = cameraStore.getAnyPossibleTrack(overrideTrackId); + if (sourceTrack !== undefined) { // eslint-disable-next-line prefer-destructuring - trackType = track.confidencePairs[0][0]; + trackType = sourceTrack.confidencePairs[0][0]; } } else { // eslint-disable-next-line no-param-reassign @@ -535,15 +538,18 @@ export default function useModeManager({ } const trackStore = cameraStore.camMap.value.get(selectedCamera.value)?.trackStore; if (trackStore) { - const newTrackId = trackStore.add( + const newTrack = trackStore.add( frame, trackType, selectedTrackId.value || undefined, overrideTrackId, - ).id; - selectTrack(newTrackId, true); + ); + if (sourceTrack) { + newTrack.confidencePairs = cloneDeep(sourceTrack.confidencePairs); + } + selectTrack(newTrack.id, true); creating = true; - return newTrackId; + return newTrack.id; } throw Error(`Could not find trackStore for Camera: ${selectedCamera.value}`); } @@ -658,6 +664,7 @@ export default function useModeManager({ undefined, trackId, ); + targetTrack.confidencePairs = cloneDeep(sourceTrack.confidencePairs); } // setFeature only upserts geometry by (key, type): drop mirrored // geometry the source no longer has so deletions propagate too. diff --git a/client/dive-common/use/useSave.ts b/client/dive-common/use/useSave.ts index 2f789d565..72ee37a6d 100644 --- a/client/dive-common/use/useSave.ts +++ b/client/dive-common/use/useSave.ts @@ -41,6 +41,7 @@ export default function useSave( readonlyMode: Ref>, ) { const pendingSaveCount = ref(0); + let globalMetadataPending = 0; const pendingChangeMaps: Record = { singleCam: { upsert: new Map(), @@ -65,8 +66,10 @@ export default function useSave( if (readonlyMode.value) { throw new Error('attempted to save in read only mode'); } + const pendingSaveSnapshot = pendingSaveCount.value; const promiseList: Promise[] = []; - let globalMetadataUpdated = false; + let canonicalConfigScheduled = false; + let canonicalConfigPersisted = false; Object.entries(pendingChangeMaps).forEach(([camera, pendingChangeMap]) => { const saveId = camera === 'singleCam' ? datasetId.value : `${datasetId.value}/${camera}`; if ( @@ -90,16 +93,23 @@ export default function useSave( pendingChangeMap.delete.clear(); })); } - if (datasetMeta && pendingChangeMap.meta > 0) { - // Save once for each camera into their own metadata file - promiseList.push(saveConfig(saveId, datasetMeta).then(() => { + const metadataSnapshot = pendingChangeMap.meta; + if (datasetMeta && metadataSnapshot > 0) { + const cameraMeta = saveId === datasetId.value + ? datasetMeta + : Object.fromEntries( + Object.entries(datasetMeta).filter(([key]) => key !== 'typeHierarchy'), + ); + if (saveId === datasetId.value) { + canonicalConfigScheduled = true; + } + promiseList.push(saveConfig(saveId, cameraMeta).then(() => { + if (saveId === datasetId.value) { + canonicalConfigPersisted = true; + } // eslint-disable-next-line no-param-reassign - pendingChangeMap.meta = 0; + pendingChangeMap.meta = Math.max(0, pendingChangeMap.meta - metadataSnapshot); })); - // Only update global if there are multiple cameras - if (saveId !== datasetId.value) { - globalMetadataUpdated = true; - } } if (pendingChangeMap.attributeUpsert.size || pendingChangeMap.attributeDelete.size) { promiseList.push(saveAttributes(datasetId.value, { @@ -121,12 +131,26 @@ export default function useSave( })); } }); - // Final save into the multi-cam metadata if multiple cameras exists - if (globalMetadataUpdated && datasetMeta && pendingChangeMaps) { - promiseList.push(saveConfig(datasetId.value, datasetMeta)); + const globalMetadataSnapshot = globalMetadataPending; + if (globalMetadataSnapshot > 0 && datasetMeta) { + canonicalConfigScheduled = true; + promiseList.push(saveConfig(datasetId.value, datasetMeta).then(() => { + canonicalConfigPersisted = true; + globalMetadataPending = Math.max(0, globalMetadataPending - globalMetadataSnapshot); + })); } - await Promise.all(promiseList); - pendingSaveCount.value = 0; + const results = await Promise.allSettled(promiseList); + const failed = results.find((result) => result.status === 'rejected') as + PromiseRejectedResult | undefined; + if (failed) { + const error = failed.reason instanceof Error + ? failed.reason + : new Error(String(failed.reason)); + Object.assign(error, { canonicalConfigPersisted }); + throw error; + } + pendingSaveCount.value = Math.max(0, pendingSaveCount.value - pendingSaveSnapshot); + return { canonicalConfigPersisted: canonicalConfigScheduled && canonicalConfigPersisted }; } function markChangesPending( @@ -153,6 +177,9 @@ export default function useSave( // eslint-disable-next-line no-param-reassign pendingChangeMap.meta += 1; }); + if (!pendingChangeMaps.singleCam) { + globalMetadataPending += 1; + } pendingSaveCount.value += 1; } else if (pendingChangeMaps[cameraName]) { const pendingChangeMap = pendingChangeMaps[cameraName]; @@ -210,6 +237,7 @@ export default function useSave( pendingChangeMap.meta = 0; }); pendingSaveCount.value = 0; + globalMetadataPending = 0; } function addCamera(cameraName: string) { diff --git a/client/platform/desktop/backend/native/common.spec.ts b/client/platform/desktop/backend/native/common.spec.ts index a9bf0f35e..676c0256d 100644 --- a/client/platform/desktop/backend/native/common.spec.ts +++ b/client/platform/desktop/backend/native/common.spec.ts @@ -11,6 +11,7 @@ import { makeEmptyAnnotationFile } from 'platform/desktop/backend/serializers/di import { CameraCorrespondences, MultiTrackRecord } from 'dive-common/apispec'; import { Attribute } from 'vue-media-annotator/use/AttributeTypes'; +import { getResponseError } from 'vue-media-annotator/utils'; import * as common from './common'; import { createWorkingDirectory, buildTrainingExitManifest } from './utils'; import beginMultiCamImport from './multiCamImport'; @@ -949,6 +950,39 @@ describe('native.common', () => { )).rejects.toThrow('no bbox and no usable polygon segmentation'); }); + it.each([ + ['malformed', '{broken'], + ['null', 'null'], + ['number', '5'], + ['string', '"annotation"'], + ])('keeps an earlier annotation write when later %s JSON is invalid', async (kind, contents) => { + const valid = '/home/user/output/valid-before-malformed.coco.json'; + const malformed = `/home/user/output/${kind}-later.json`; + const project = common.getProjectDir(settings, 'projectid1'); + await fs.writeFile(valid, cocoWithRle(1)); + await fs.writeFile(malformed, contents); + const writeFile = vi.spyOn(fs, 'writeFile'); + + try { + await common.ingestDataFiles( + settings, + 'projectid1', + [valid, malformed], + ).catch(() => undefined); + + expect(await fs.pathExists( + npath.join(project.auxDirAbsPath, `imported_${npath.basename(valid)}`), + )).toBe(true); + const annotationWrites = writeFile.mock.calls.filter(([path, data]) => ( + npath.basename(String(path)).startsWith('result_') + && Object.prototype.hasOwnProperty.call(JSON.parse(String(data)).tracks, '1') + )); + expect(annotationWrites).toHaveLength(1); + } finally { + writeFile.mockRestore(); + } + }); + it('getPipelineList lists pipelines', async () => { const exists = await fs.pathExists(settings.viamePath); expect(exists).toBe(true); @@ -1026,6 +1060,282 @@ describe('native.common', () => { expect(data.imageData[1].timestamp).toBeUndefined(); }); + it('saveConfig sets, clears, and atomically rejects a type hierarchy', async () => { + const legacyProject = common.getProjectDir(settings, 'projectid1'); + await common.saveProjectConfig( + legacyProject.basePath, + await fs.readJSON(legacyProject.datasetFileAbsPath), + ); + await common.saveConfig(settings, 'projectid1', { + typeHierarchy: { shark: 'fish' }, + }); + let meta = await common.loadConfig(settings, 'projectid1', urlMapper); + expect(meta.typeHierarchy).toEqual({ shark: 'fish' }); + + const project = common.getProjectDir(settings, 'projectid1'); + const beforeInvalid = await fs.readFile(project.datasetFileAbsPath, 'utf8'); + await expect(common.saveConfig(settings, 'projectid1', { + typeHierarchy: { fish: 'fish' }, + confidenceFilters: { default: 0.9 }, + })).rejects.toThrow( + 'Type hierarchy is invalid: self edge "fish -> fish". No configuration was changed.', + ); + expect(await fs.readFile(project.datasetFileAbsPath, 'utf8')).toBe(beforeInvalid); + + await common.saveConfig(settings, 'projectid1', { typeHierarchy: null }); + meta = await common.loadConfig(settings, 'projectid1', urlMapper); + expect(meta.typeHierarchy).toBeUndefined(); + }); + + it('an unrelated direct save preserves invalid hierarchy storage until repaired', async () => { + let project = common.getProjectDir(settings, 'projectid1'); + const raw = await fs.readJSON(project.datasetFileAbsPath); + raw.typeHierarchy = ['corrupt']; + await common.saveProjectConfig(project.basePath, raw); + project = common.getProjectDir(settings, 'projectid1'); + + await common.saveConfig(settings, 'projectid1', { + confidenceFilters: { default: 0.7 }, + }); + let saved = await fs.readJSON(common.getProjectDir(settings, 'projectid1').datasetFileAbsPath); + expect(saved.typeHierarchy).toEqual(['corrupt']); + + await common.saveConfig(settings, 'projectid1', { typeHierarchy: {} }); + saved = await fs.readJSON(common.getProjectDir(settings, 'projectid1').datasetFileAbsPath); + expect(saved.typeHierarchy).toBeUndefined(); + await common.saveConfig(settings, 'projectid1', { + typeHierarchy: { tuna: 'fish' }, + }); + saved = await fs.readJSON(common.getProjectDir(settings, 'projectid1').datasetFileAbsPath); + expect(saved.typeHierarchy).toEqual({ tuna: 'fish' }); + }); + + it('imports overwrite, additive, and explicit-empty hierarchy instructions', async () => { + const overwrite = '/home/user/output/hierarchy-overwrite.json'; + const additive = '/home/user/output/hierarchy-additive.json'; + const empty = '/home/user/output/hierarchy-empty.json'; + const cleared = '/home/user/output/hierarchy-null.json'; + await fs.writeJSON(overwrite, { typeHierarchy: { shark: 'fish' } }); + await fs.writeJSON(additive, { typeHierarchy: { tuna: 'fish' } }); + await fs.writeJSON(empty, { typeHierarchy: {} }); + await fs.writeJSON(cleared, { typeHierarchy: null }); + + await common.dataFileImport(settings, 'projectid1', overwrite); + await common.dataFileImport(settings, 'projectid1', additive, true); + let meta = await common.loadConfig(settings, 'projectid1', urlMapper); + expect(meta.typeHierarchy).toEqual({ shark: 'fish', tuna: 'fish' }); + + await common.dataFileImport(settings, 'projectid1', empty, true); + meta = await common.loadConfig(settings, 'projectid1', urlMapper); + expect(meta.typeHierarchy).toEqual({ shark: 'fish', tuna: 'fish' }); + await common.dataFileImport(settings, 'projectid1', cleared, true); + meta = await common.loadConfig(settings, 'projectid1', urlMapper); + expect(meta.typeHierarchy).toBeUndefined(); + + await common.dataFileImport(settings, 'projectid1', overwrite); + await common.dataFileImport(settings, 'projectid1', empty); + meta = await common.loadConfig(settings, 'projectid1', urlMapper); + expect(meta.typeHierarchy).toBeUndefined(); + }); + + it('rejects additive hierarchy conflicts without writes and allows corrected retry', async () => { + const imported = '/home/user/output/hierarchy-conflict.json'; + const legacyProject = common.getProjectDir(settings, 'projectid1'); + await common.saveProjectConfig( + legacyProject.basePath, + await fs.readJSON(legacyProject.datasetFileAbsPath), + ); + await common.saveConfig(settings, 'projectid1', { + typeHierarchy: { shark: 'fish' }, + confidenceFilters: { default: 0.2 }, + }); + await fs.writeJSON(imported, { + typeHierarchy: { shark: 'animal' }, + confidenceFilters: { default: 0.9 }, + }); + const project = await common.getValidatedProjectDir(settings, 'projectid1'); + const before = await fs.readFile(project.datasetFileAbsPath, 'utf8'); + + await expect(common.dataFileImport( + settings, + 'projectid1', + imported, + true, + )).rejects.toThrow( + 'Type hierarchy is invalid: conflicting parents for "shark": "fish" and "animal". ' + + 'No configuration was changed.', + ); + expect(await fs.readFile(project.datasetFileAbsPath, 'utf8')).toBe(before); + expect(await fs.pathExists(npath.join(project.auxDirAbsPath, 'imported_hierarchy-conflict.json'))) + .toBe(false); + + await fs.writeJSON(imported, { typeHierarchy: { tuna: 'fish' } }); + await common.dataFileImport(settings, 'projectid1', imported, true); + const saved = await common.loadConfig(settings, 'projectid1', urlMapper); + expect(saved.typeHierarchy).toEqual({ shark: 'fish', tuna: 'fish' }); + }); + + it('preflights ordered hierarchy config batches and cleans every auxiliary copy on failure', async () => { + const annotation = '/home/user/output/annotation-before-config.json'; + const first = '/home/user/output/hierarchy-first.json'; + const second = '/home/user/output/hierarchy-second.json'; + await fs.writeJSON(annotation, { 0: { trackId: 0 } }); + await fs.writeJSON(first, { + typeHierarchy: { shark: 'fish' }, + datasetInfo: { first: true, replaced: 'first' }, + }); + await fs.writeJSON(second, { typeHierarchy: { fish: 'shark' } }); + const project = await common.getValidatedProjectDir(settings, 'projectid1'); + const before = await fs.readFile(project.datasetFileAbsPath, 'utf8'); + const annotationsBefore = await fs.readFile(project.trackFileAbsPath, 'utf8'); + + await expect(common.ingestDataFiles( + settings, + 'projectid1', + [annotation, first, second], + undefined, + undefined, + true, + )).rejects.toThrow( + 'Type hierarchy is invalid: cycle fish -> shark -> fish. No configuration was changed.', + ); + expect(await fs.readFile(project.datasetFileAbsPath, 'utf8')).toBe(before); + expect(await fs.readFile(project.trackFileAbsPath, 'utf8')).toBe(annotationsBefore); + expect(await fs.pathExists(npath.join(project.auxDirAbsPath, 'imported_hierarchy-first.json'))) + .toBe(false); + expect(await fs.pathExists(npath.join(project.auxDirAbsPath, 'imported_hierarchy-second.json'))) + .toBe(false); + + await fs.writeJSON(second, { + typeHierarchy: { tuna: 'fish' }, + datasetInfo: { second: true, replaced: 'second' }, + }); + const result = await common.ingestDataFiles( + settings, + 'projectid1', + [first, second], + undefined, + undefined, + true, + ); + expect(result.meta.typeHierarchy).toEqual({ shark: 'fish', tuna: 'fish' }); + expect(result.meta.datasetInfo).toEqual({ + first: true, + second: true, + replaced: 'second', + }); + + const overwriteResult = await common.ingestDataFiles( + settings, + 'projectid1', + [first, second], + ); + expect(overwriteResult.meta.typeHierarchy).toEqual({ tuna: 'fish' }); + expect(overwriteResult.meta.datasetInfo).toEqual({ second: true, replaced: 'second' }); + }); + + it('parses each config once and executes the exact ordered hierarchy candidate', async () => { + const first = '/home/user/output/parse-once-first.json'; + const second = '/home/user/output/parse-once-second.json'; + const legacyProject = common.getProjectDir(settings, 'projectid1'); + await common.saveProjectConfig( + legacyProject.basePath, + await fs.readJSON(legacyProject.datasetFileAbsPath), + ); + await common.saveConfig(settings, 'projectid1', { + typeHierarchy: { shark: 'fish' }, + }); + await fs.writeJSON(first, { + typeHierarchy: { tuna: 'fish' }, + datasetInfo: { sequence: 'first' }, + }); + await fs.writeJSON(second, { + typeHierarchy: { mako: 'shark' }, + datasetInfo: { sequence: 'second' }, + }); + const readFile = vi.spyOn(fs, 'readFile'); + + try { + const result = await common.ingestDataFiles( + settings, + 'projectid1', + [first, second], + undefined, + undefined, + true, + ); + + expect(result.meta.typeHierarchy).toEqual({ + mako: 'shark', + shark: 'fish', + tuna: 'fish', + }); + expect(result.meta.datasetInfo).toEqual({ sequence: 'second' }); + expect(readFile.mock.calls.filter(([path]) => path === first)).toHaveLength(1); + expect(readFile.mock.calls.filter(([path]) => path === second)).toHaveLength(1); + } finally { + readFile.mockRestore(); + } + }); + + it('surfaces the exact hierarchy error through the desktop public import boundary', async () => { + const imported = '/home/user/output/public-import-conflict.json'; + const expected = 'Type hierarchy is invalid: conflicting parents for "shark": "fish" and ' + + '"animal". No configuration was changed.'; + const legacyProject = common.getProjectDir(settings, 'projectid1'); + await common.saveProjectConfig( + legacyProject.basePath, + await fs.readJSON(legacyProject.datasetFileAbsPath), + ); + await common.saveConfig(settings, 'projectid1', { + typeHierarchy: { shark: 'fish' }, + }); + await fs.writeJSON(imported, { typeHierarchy: { shark: 'animal' } }); + let surfacedError: unknown; + + try { + await common.dataFileImport(settings, 'projectid1', imported, true); + } catch (error) { + surfacedError = error; + } + + expect(surfacedError).toBeInstanceOf(Error); + expect((surfacedError as Error).message).toBe(expected); + expect(getResponseError(surfacedError)).toBe(expected); + }); + + it('exports only a valid non-empty type hierarchy and writes no invalid export', async () => { + const output = '/home/user/output/exported-config.json'; + const legacyProject = common.getProjectDir(settings, 'projectid1'); + await common.saveProjectConfig( + legacyProject.basePath, + await fs.readJSON(legacyProject.datasetFileAbsPath), + ); + await common.saveConfig(settings, 'projectid1', { + typeHierarchy: { shark: 'fish' }, + }); + await common.exportConfiguration(settings, { id: 'projectid1', path: output }); + expect((await fs.readJSON(output)).typeHierarchy).toEqual({ shark: 'fish' }); + + await common.saveConfig(settings, 'projectid1', { typeHierarchy: null }); + await common.exportConfiguration(settings, { id: 'projectid1', path: output }); + expect((await fs.readJSON(output)).typeHierarchy).toBeUndefined(); + + const project = common.getProjectDir(settings, 'projectid1'); + const raw = await fs.readJSON(project.datasetFileAbsPath); + raw.typeHierarchy = { fish: 'fish' }; + await fs.writeJSON(project.datasetFileAbsPath, raw); + await fs.remove(output); + await expect(common.exportConfiguration( + settings, + { id: 'projectid1', path: output }, + )).rejects.toThrow( + 'Type hierarchy is invalid: self edge "fish -> fish". ' + + 'No configuration file was exported.', + ); + expect(await fs.pathExists(output)).toBe(false); + }); + it('loadJsonConfig parses per-camera frame timestamps for multicam datasets', async () => { const data = await common.loadConfig(settings, 'stereoDataset', urlMapper); expect(data.multiCamMedia).not.toBeNull(); @@ -1340,7 +1650,12 @@ describe('native.common', () => { }; seededBase.cameraTransformTypes = { 'left::right': 'similarity' }; seededBase.cameraRegistrationSource = { model: 'seeded' }; + seededBase.typeHierarchy = { shark: 'fish' }; await fs.writeJSON(baseDir.datasetFileAbsPath, seededBase); + const cameraDir = common.getProjectDir(settings, `${baseId}/left`); + const seededCamera = await common.loadJsonConfig(cameraDir.datasetFileAbsPath); + seededCamera.typeHierarchy = { whale: 'mammal' }; + await fs.writeJSON(cameraDir.datasetFileAbsPath, seededCamera); await common.dataFileImport( settings, @@ -1361,6 +1676,30 @@ describe('native.common', () => { expect(baseMeta.cameraCorrespondences).toStrictEqual(seededBase.cameraCorrespondences); expect(baseMeta.cameraTransformTypes).toStrictEqual(seededBase.cameraTransformTypes); expect(baseMeta.cameraRegistrationSource).toStrictEqual(seededBase.cameraRegistrationSource); + + const hierarchyImport = '/home/user/output/multicam-hierarchy.json'; + await fs.writeJSON(hierarchyImport, { typeHierarchy: { shark: 'animal' } }); + const parentBeforeConflict = await fs.readFile(baseDir.datasetFileAbsPath, 'utf8'); + const cameraBeforeConflict = await fs.readFile(cameraDir.datasetFileAbsPath, 'utf8'); + await expect(common.dataFileImport( + settings, + `${baseId}/left`, + hierarchyImport, + true, + )).rejects.toThrow( + 'Type hierarchy is invalid: conflicting parents for "shark": "fish" and "animal". ' + + 'No configuration was changed.', + ); + expect(await fs.readFile(baseDir.datasetFileAbsPath, 'utf8')).toBe(parentBeforeConflict); + expect(await fs.readFile(cameraDir.datasetFileAbsPath, 'utf8')).toBe(cameraBeforeConflict); + + await fs.writeJSON(hierarchyImport, { typeHierarchy: { tuna: 'fish' } }); + await common.dataFileImport(settings, `${baseId}/left`, hierarchyImport, true); + const resolvedHierarchy = { shark: 'fish', tuna: 'fish' }; + expect((await common.loadConfig(settings, baseId, urlMapper)).typeHierarchy) + .toEqual(resolvedHierarchy); + expect((await common.loadConfig(settings, `${baseId}/left`, urlMapper)).typeHierarchy) + .toEqual(resolvedHierarchy); }); it('saveConfig writes per-camera registration files (pairs + points) and reloads them', async () => { diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts index c7adedc49..7be5f49c0 100644 --- a/client/platform/desktop/backend/native/common.ts +++ b/client/platform/desktop/backend/native/common.ts @@ -58,6 +58,12 @@ import { cleanString, filterByGlob, makeid, strNumericCompare, } from 'platform/desktop/sharedUtils'; import { parseFrameTimestamp } from 'dive-common/frameTimestamp'; +import { + HierarchyWrite, + normalizeTypeHierarchy, + resolveTypeHierarchy, + TypeHierarchyError, +} from 'dive-common/typeHierarchy'; import processTrackAttributes from './attributeProcessor'; import { upgrade } from './migrations'; @@ -91,6 +97,16 @@ const PortableConfigFileNameLegacy = 'meta.json'; const CsvFileName = /^.*\.csv$/i; const YAMLFileName = /^.*\.ya?ml$/i; +const invalidHierarchyMessage = (reason: string) => ( + `Type hierarchy is invalid: ${reason}. No configuration was changed.` +); + +const corruptHierarchyExportMessage = (reason: string) => ( + `Type hierarchy is invalid: ${reason}. No configuration file was exported.` +); + +class DataFileJsonParseError extends Error {} + /** * Resolve the project dataset.json path: prefer dataset.json, fall back to * legacy meta.json for existing datasets, else the preferred name for new projects. @@ -341,7 +357,7 @@ async function _loadAsJson(abspath: string) { try { return JSON.parse(rawBuffer); } catch (err) { - throw new Error(`Unable to parse ${abspath}: ${err}`); + throw new DataFileJsonParseError(`Unable to parse ${abspath}: ${err}`); } } @@ -535,6 +551,15 @@ async function loadConfig( ): Promise { const projectDirData = await getValidatedProjectDir(settings, datasetId); const projectMetaData = await loadJsonConfig(projectDirData.datasetFileAbsPath); + const { parentId, cameraName } = parseCompositeDatasetId(datasetId); + if (cameraName) { + const hierarchy = await loadCanonicalHierarchy(settings, parentId); + if (hierarchy === null) { + delete projectMetaData.typeHierarchy; + } else { + projectMetaData.typeHierarchy = hierarchy as Record; + } + } // Load the standalone camera registration (transforms + correspondences) // from the per-camera *_registration.json files, if present; the dataset @@ -1227,51 +1252,76 @@ async function saveConfig(settings: Settings, datasetId: string, args: DatasetCo projectDirInfo.basePath, 'meta', ); - const existing = await loadJsonConfig( - resolveDatasetFileAbsPath(projectDirInfo.basePath), - ); - if (args.confidenceFilters) { - existing.confidenceFilters = args.confidenceFilters; - } - if (args.imageEnhancements) { - existing.imageEnhancements = args.imageEnhancements; - } - if (args.customTypeStyling) { - existing.customTypeStyling = args.customTypeStyling; - } - if (args.customGroupStyling) { - existing.customGroupStyling = args.customGroupStyling; - } - if (args.attributes) { - existing.attributes = args.attributes; - } - if (args.timeFilters !== undefined) { - existing.timeFilters = args.timeFilters; - } - if (args.error) { - existing.error = args.error; - } - if (args.datasetInfo) { - existing.datasetInfo = args.datasetInfo; - } - - // The camera registration (transforms + the points behind them) is - // persisted as standalone _to__registration.json files - // in the dataset directory rather than embedded in dataset.json, so each - // camera's registration is easy to find, hand-edit, and consume as a - // self-contained artifact. There is deliberately never a single all-pairs - // file. - if (args.cameraHomographies || args.cameraCorrespondences || args.cameraTransformTypes - || args.cameraRegistrationSource) { - await saveRegistrationToDatasetDir( - projectDirInfo.basePath, - args, - referenceCameraName(existing), + try { + const existing = await loadJsonConfig( + resolveDatasetFileAbsPath(projectDirInfo.basePath), ); - } + const { parentId, cameraName } = parseCompositeDatasetId(datasetId); + const hierarchyPresent = Object.prototype.hasOwnProperty.call(args, 'typeHierarchy'); + if (cameraName) { + if (hierarchyPresent) { + await saveConfig(settings, parentId, { typeHierarchy: args.typeHierarchy }); + } + delete existing.typeHierarchy; + } + let hierarchyWrite: HierarchyWrite; + try { + hierarchyWrite = resolveTypeHierarchy( + existing.typeHierarchy, + !cameraName && hierarchyPresent, + args.typeHierarchy, + 'save', + ); + } catch (error) { + if (error instanceof TypeHierarchyError) { + throw new Error(invalidHierarchyMessage(error.reason)); + } + throw error; + } + if (hierarchyWrite.action === 'set') { + existing.typeHierarchy = { ...hierarchyWrite.hierarchy }; + } else if (hierarchyWrite.action === 'delete') { + delete existing.typeHierarchy; + } + if (args.confidenceFilters) { + existing.confidenceFilters = args.confidenceFilters; + } + if (args.imageEnhancements) { + existing.imageEnhancements = args.imageEnhancements; + } + if (args.customTypeStyling) { + existing.customTypeStyling = args.customTypeStyling; + } + if (args.customGroupStyling) { + existing.customGroupStyling = args.customGroupStyling; + } + if (args.attributes) { + existing.attributes = args.attributes; + } + if (args.timeFilters !== undefined) { + existing.timeFilters = args.timeFilters; + } + if (args.error) { + existing.error = args.error; + } + if (args.datasetInfo) { + existing.datasetInfo = args.datasetInfo; + } - await saveProjectConfig(projectDirInfo.basePath, existing); - await release(); + // Registration files remain separate so each camera pair has one persisted owner. + if (args.cameraHomographies || args.cameraCorrespondences || args.cameraTransformTypes + || args.cameraRegistrationSource) { + await saveRegistrationToDatasetDir( + projectDirInfo.basePath, + args, + referenceCameraName(existing), + ); + } + + await saveProjectConfig(projectDirInfo.basePath, existing); + } finally { + await release(); + } } async function saveAttributes(settings: Settings, datasetId: string, args: SaveAttributeArgs) { @@ -1318,12 +1368,14 @@ async function saveAttributeTrackFilters( async function _ingestFilePath( settings: Settings, - datasetId: string, - path: string, - imageMap?: Map, - additive = false, - additivePrepend = '', -): Promise<[(DatasetConfigMutable & { fps?: number }), string[]] | null> { + plan: IngestFilePlan, + imageMap: Map | undefined, +): Promise<[ + (DatasetConfigMutable & { fps?: number }), string[], boolean, string, +] | null> { + const { + datasetId, path, additive, additivePrepend, configMeta, + } = plan; if (!fs.existsSync(path)) { return null; } @@ -1344,7 +1396,10 @@ async function _ingestFilePath( let annotations = dive.makeEmptyAnnotationFile(); const meta: DatasetConfigMutable & { fps?: number, execTime?: number } = {}; let metadataConfig = false; - if (JsonFileName.test(path)) { + if (configMeta) { + Object.assign(meta, configMeta); + metadataConfig = true; + } else if (JsonFileName.test(path)) { const jsonObject = await _loadAsJson(path); if (nistSerializers.confirmNistFormat(jsonObject)) { // NIST json file @@ -1352,10 +1407,6 @@ async function _ingestFilePath( annotations.tracks = data.tracks; annotations.groups = data.groups; meta.fps = data.fps; - } else if (DatasetConfigMutableKeys.some((key) => key in jsonObject)) { - // DIVE Configuration File (attributes, styles, FPS, …) - merge(meta, pick(jsonObject, DatasetConfigMutableKeys)); - metadataConfig = true; } else if (coco.isCocoJson(jsonObject)) { const [parsedAnnotations, parsedMeta, cocoWarnings] = await coco.parseFile(path); annotations = parsedAnnotations; @@ -1420,7 +1471,153 @@ async function _ingestFilePath( await _saveSerialized(settings, datasetId, annotations, true); } - return [meta, warnings]; + return [meta, warnings, metadataConfig, newPath]; +} + +type StagedConfigImport = DatasetConfigMutable & { fps?: number }; + +interface IngestFilePlan { + datasetId: string; + path: string; + additive: boolean; + additivePrepend: string; + configMeta?: StagedConfigImport; +} + +async function loadCanonicalHierarchy(settings: Settings, datasetId: string): Promise { + const { parentId, cameraName } = parseCompositeDatasetId(datasetId); + const canonicalId = cameraName ? parentId : datasetId; + const projectDir = getProjectDir(settings, canonicalId); + if (!await fs.pathExists(projectDir.datasetFileAbsPath)) { + return null; + } + const config = await loadJsonConfig(projectDir.datasetFileAbsPath); + return Object.prototype.hasOwnProperty.call(config, 'typeHierarchy') + ? config.typeHierarchy + : null; +} + +function mergeImportedConfig( + target: DatasetConfigMutable, + incoming: DatasetConfigMutable, +) { + const hierarchyPresent = Object.prototype.hasOwnProperty.call(incoming, 'typeHierarchy'); + const hierarchy = incoming.typeHierarchy; + const nonHierarchy = { ...incoming }; + delete nonHierarchy.typeHierarchy; + merge(target, nonHierarchy); + if (hierarchyPresent) { + if (hierarchy === null) { + // eslint-disable-next-line no-param-reassign + delete target.typeHierarchy; + } else { + // eslint-disable-next-line no-param-reassign + target.typeHierarchy = hierarchy ? { ...hierarchy } : hierarchy; + } + } +} + +function mergeStagedImportedConfig( + target: DatasetConfigMutable, + incoming: DatasetConfigMutable, + additive: boolean, +) { + const hierarchyPresent = Object.prototype.hasOwnProperty.call(incoming, 'typeHierarchy'); + const hierarchy = incoming.typeHierarchy; + const nonHierarchy = { ...incoming }; + delete nonHierarchy.typeHierarchy; + const { datasetInfo } = nonHierarchy; + delete nonHierarchy.datasetInfo; + merge(target, nonHierarchy); + if (datasetInfo) { + // eslint-disable-next-line no-param-reassign + target.datasetInfo = additive + ? { ...(target.datasetInfo || {}), ...datasetInfo } + : datasetInfo; + } + if (hierarchyPresent) { + // eslint-disable-next-line no-param-reassign + target.typeHierarchy = hierarchy === null ? null : { ...hierarchy }; + } +} + +async function preflightIngestFiles( + settings: Settings, + datasetId: string, + absPaths: string[], + multiCamResults: Record | undefined, + additive: boolean, + additivePrepend: string, +): Promise { + let hierarchyCandidate = await loadCanonicalHierarchy(settings, datasetId); + const plan: IngestFilePlan[] = [ + ...absPaths.map((path) => ({ + datasetId, + path, + additive, + additivePrepend, + })), + ...Object.entries(multiCamResults || {}).map(([cameraName, path]) => ({ + datasetId: `${datasetId}/${cameraName}`, + path, + additive: false, + additivePrepend: '', + })), + ]; + for (let index = 0; index < plan.length; index += 1) { + const entry = plan[index]; + const { path } = entry; + if (fs.existsSync(path) && fs.statSync(path).size > 0 && JsonFileName.test(path)) { + // Configuration entries keep their parsed, fully resolved metadata in this + // plan so execution cannot re-read the source or repeat hierarchy policy. + let jsonObject; + try { + // eslint-disable-next-line no-await-in-loop + jsonObject = await _loadAsJson(path); + } catch (error) { + if (error instanceof DataFileJsonParseError) { + // Defer syntax failures so earlier annotations retain ordered partial writes. + jsonObject = undefined; + } else { + throw error; + } + } + if (jsonObject !== undefined + && jsonObject !== null + && typeof jsonObject === 'object' + && !Array.isArray(jsonObject) + && !nistSerializers.confirmNistFormat(jsonObject) + && DatasetConfigMutableKeys.some((key) => key in jsonObject)) { + try { + const configMeta = pick( + jsonObject, + DatasetConfigMutableKeys, + ) as StagedConfigImport; + const write = resolveTypeHierarchy( + hierarchyCandidate, + Object.prototype.hasOwnProperty.call(jsonObject, 'typeHierarchy'), + jsonObject.typeHierarchy, + additive ? 'additive' : 'overwrite', + ); + delete configMeta.typeHierarchy; + if (write.action === 'set') { + hierarchyCandidate = write.hierarchy; + configMeta.typeHierarchy = { ...write.hierarchy }; + } else if (write.action === 'delete') { + hierarchyCandidate = null; + configMeta.typeHierarchy = null; + } + entry.configMeta = configMeta; + } catch (error) { + if (error instanceof TypeHierarchyError) { + throw new Error(invalidHierarchyMessage(error.reason)); + } + throw error; + } + } + } + } + return plan; } /** @@ -1450,35 +1647,39 @@ async function ingestDataFiles( warnings: string[]; }> { const processedFiles = []; // which files were processed to generate the detections - const meta = {}; + const meta: DatasetConfigMutable & { fps?: number } = {}; let outwarnings: string[] = []; - for (let i = 0; i < absPaths.length; i += 1) { - const path = absPaths[i]; - // eslint-disable-next-line no-await-in-loop - const results = await _ingestFilePath(settings, datasetId, path, imageMap, additive, additivePrepend); - if (results !== null) { - const [newMeta, warnings] = results; - outwarnings = outwarnings.concat(warnings); - merge(meta, newMeta); - processedFiles.push(path); - } - } - // processing of multiCam results - if (multiCamResults) { - const cameraAndPath = Object.entries(multiCamResults); - for (let i = 0; i < cameraAndPath.length; i += 1) { - const cameraName = cameraAndPath[i][0]; - const path = cameraAndPath[i][1]; - const cameraDatasetId = `${datasetId}/${cameraName}`; + const plan = await preflightIngestFiles( + settings, + datasetId, + absPaths, + multiCamResults, + additive, + additivePrepend, + ); + const importedConfigCopies: string[] = []; + try { + for (let i = 0; i < plan.length; i += 1) { + const entry = plan[i]; // eslint-disable-next-line no-await-in-loop - const results = await _ingestFilePath(settings, cameraDatasetId, path, imageMap); + const results = await _ingestFilePath( + settings, + entry, + imageMap, + ); if (results !== null) { - const [newMeta, warnings] = results; + const [newMeta, warnings, metadataConfig, auxiliaryPath] = results; outwarnings = outwarnings.concat(warnings); - merge(meta, newMeta); - processedFiles.push(path); + mergeStagedImportedConfig(meta, newMeta, additive); + if (metadataConfig) { + importedConfigCopies.push(auxiliaryPath); + } + processedFiles.push(entry.path); } } + } catch (error) { + await Promise.all(importedConfigCopies.map((path) => fs.remove(path))); + throw error; } return { processedFiles, meta, warnings: outwarnings }; @@ -2022,7 +2223,13 @@ async function dataFileImport(settings: Settings, id: string, path: string, addi additive, additivePrepend, ); - merge(jsonConfig, result.meta); + const { parentId, cameraName } = parseCompositeDatasetId(id); + const cameraMeta = { ...result.meta }; + if (cameraName) { + delete cameraMeta.typeHierarchy; + delete jsonConfig.typeHierarchy; + } + mergeImportedConfig(jsonConfig, cameraMeta); // Assign datasetInfo explicitly; the deep-merge above would keep keys an Overwrite // import meant to drop. Like the server, Overwrite replaces the block wholesale while // an additive import merges per-key (imported values win). @@ -2036,13 +2243,19 @@ async function dataFileImport(settings: Settings, id: string, path: string, addi // loaded by the viewer from the base dataset's metadata, so an import // targeted at one camera of a multicam dataset must update the base too. // Do not sync per-camera imageEnhancements or camera-registration fields. - const { parentId, cameraName } = parseCompositeDatasetId(id); - if (cameraName && MulticamSharedMutableKeys.some((key) => key in result.meta)) { + const hierarchyPresent = Object.prototype.hasOwnProperty.call(result.meta, 'typeHierarchy'); + if (cameraName && ( + hierarchyPresent || MulticamSharedMutableKeys.some((key) => key in result.meta) + )) { const baseProjectDir = getProjectDir(settings, parentId); if (await fs.pathExists(baseProjectDir.datasetFileAbsPath)) { const baseMeta = await loadJsonConfig(baseProjectDir.datasetFileAbsPath); const existingBaseDatasetInfo = baseMeta.datasetInfo; - merge(baseMeta, pick(result.meta, MulticamSharedMutableKeys)); + const parentMeta = pick(result.meta, MulticamSharedMutableKeys); + if (hierarchyPresent) { + parentMeta.typeHierarchy = result.meta.typeHierarchy; + } + mergeImportedConfig(baseMeta, parentMeta); if (result.meta.datasetInfo) { baseMeta.datasetInfo = additive ? { ...(existingBaseDatasetInfo ?? {}), ...result.meta.datasetInfo } @@ -2395,11 +2608,36 @@ async function exportDataset(settings: Settings, args: ExportDatasetArgs) { async function exportConfiguration(settings: Settings, args: ExportConfigurationArgs) { const projectDirInfo = await getValidatedProjectDir(settings, args.id); const meta = await loadJsonConfig(projectDirInfo.datasetFileAbsPath); + const { cameraName } = parseCompositeDatasetId(args.id); + if (cameraName) { + const hierarchy = await loadCanonicalHierarchy(settings, args.id); + if (hierarchy === null) { + delete meta.typeHierarchy; + } else { + meta.typeHierarchy = hierarchy as Record; + } + } const output: DatasetConfigMutable & { version: number} = { version: meta.version }; + let hierarchy; + try { + hierarchy = Object.prototype.hasOwnProperty.call(meta, 'typeHierarchy') + ? normalizeTypeHierarchy(meta.typeHierarchy) + : undefined; + } catch (error) { + if (error instanceof TypeHierarchyError) { + throw new Error(corruptHierarchyExportMessage(error.reason)); + } + throw error; + } if (DatasetConfigMutableKeys.some((key) => key in meta)) { // DIVE Configuration File fields (attributes, styles, FPS, …) merge(output, pick(meta, DatasetConfigMutableKeys)); } + if (hierarchy) { + output.typeHierarchy = { ...hierarchy }; + } else { + delete output.typeHierarchy; + } await fs.writeJSON(args.path, output); return args.path; } diff --git a/client/platform/desktop/backend/native/multicamExport.ts b/client/platform/desktop/backend/native/multicamExport.ts index 385f66262..cc73fed87 100644 --- a/client/platform/desktop/backend/native/multicamExport.ts +++ b/client/platform/desktop/backend/native/multicamExport.ts @@ -75,6 +75,7 @@ async function writeDatasetExportContents( datasetId: string, excludeBelowThreshold: boolean, typeFilter: Set, + includeHierarchy = true, ): Promise { const projectDirInfo = await getValidatedProjectDir(settings, datasetId); const meta = await loadJsonConfig(projectDirInfo.datasetFileAbsPath); @@ -86,6 +87,9 @@ async function writeDatasetExportContents( await fs.ensureDir(destDir); const exportMeta = buildExportMetaJson(meta); + if (!includeHierarchy) { + delete exportMeta.typeHierarchy; + } if (meta.metadataFile) { if (!await fs.pathExists(meta.metadataFile)) { throw new Error(`Metadata attachment is missing: ${meta.metadataFile}`); @@ -188,6 +192,7 @@ export async function exportMulticamEverything( `${parentId}/${cameraName}`, args.exclude, args.typeFilter, + false, ); } diff --git a/client/platform/web-girder/views/Upload.spec.ts b/client/platform/web-girder/views/Upload.spec.ts index d4478dfd2..249c72d06 100644 --- a/client/platform/web-girder/views/Upload.spec.ts +++ b/client/platform/web-girder/views/Upload.spec.ts @@ -326,6 +326,27 @@ describe('Upload pending rows', () => { ]); }); + it('keeps a validated type hierarchy configuration in the upload package', async () => { + pick([file('dive.mp4'), file('hierarchy.config.json')]); + vi.mocked(validateUploadGroup).mockResolvedValue({ + data: validation({ + roles: { + media: ['dive.mp4'], + datasetConfig: ['hierarchy.config.json'], + }, + }), + } as never); + + const wrapper = mountUpload(); + await wrapper.vm.openImport('video'); + + const [row] = wrapper.vm.pendingUploads; + expect(row.uploadFiles.map((entry: File) => entry.name)).toEqual([ + 'dive.mp4', + 'hierarchy.config.json', + ]); + }); + it('starts only one upload when Start upload is clicked twice', async () => { pick([file('dive.mp4')]); vi.mocked(validateUploadGroup).mockResolvedValue({ diff --git a/client/src/BaseFilterControls.ts b/client/src/BaseFilterControls.ts index 12c9ac0e9..3e727280a 100644 --- a/client/src/BaseFilterControls.ts +++ b/client/src/BaseFilterControls.ts @@ -52,8 +52,8 @@ export default abstract class BaseFilterControls { /* Time filtering values */ timeFilters: Ref<[number, number] | null>; - /* The types informed by meta configuration */ - private defaultTypes: Ref; + /* The types informed by explicit meta configuration */ + configuredTypes: Ref; /* Collect all known types from confidence pairs */ allTypes: Ref; @@ -61,6 +61,9 @@ export default abstract class BaseFilterControls { /* Types currently assigned to at least one annotation */ usedTypes: Ref; + /* Types that should be persisted through type/style configuration */ + usedPlusConfiguredTypes: Ref; + /* Categorical types checked "ON" by the user */ checkedTypes: Ref; @@ -92,7 +95,7 @@ export default abstract class BaseFilterControls { this.timeFilters = ref(null); - this.defaultTypes = ref([]); + this.configuredTypes = ref([]); this.sorted = params.sorted; @@ -106,29 +109,26 @@ export default abstract class BaseFilterControls { this.disableAnnotationFilters = ref(false); - this.allTypes = computed(() => { + this.usedTypes = computed(() => { const typeSet = new Set(); this.sorted.value.forEach((annotation) => { annotation.confidencePairs.forEach(([name]) => { typeSet.add(name); }); }); - this.defaultTypes.value.forEach((type) => { - typeSet.add(type); - }); return Array.from(typeSet); }); - this.usedTypes = computed(() => { - const typeSet = new Set(); - this.sorted.value.forEach((annotation) => { - annotation.confidencePairs.forEach(([name]) => { - typeSet.add(name); - }); + this.usedPlusConfiguredTypes = computed(() => { + const typeSet = new Set(this.usedTypes.value); + this.configuredTypes.value.forEach((type) => { + typeSet.add(type); }); return Array.from(typeSet); }); + this.allTypes = this.usedPlusConfiguredTypes; + this.checkedTypes = ref(Array.from(this.allTypes.value)); this.filteredAnnotations = ref([]); @@ -165,8 +165,8 @@ export default abstract class BaseFilterControls { importTypes(types: string[], userInteraction = true) { types.forEach((type) => { - if (!this.defaultTypes.value.includes(type)) { - this.defaultTypes.value.push(type); + if (!this.configuredTypes.value.includes(type)) { + this.configuredTypes.value.push(type); } }); if (userInteraction) { @@ -174,12 +174,17 @@ export default abstract class BaseFilterControls { } } - deleteType(type: string) { - if (this.defaultTypes.value.includes(type)) { - this.defaultTypes.value.splice(this.defaultTypes.value.indexOf(type), 1); + protected deleteTypeConfiguration(type: string) { + if (this.configuredTypes.value.includes(type)) { + this.configuredTypes.value.splice(this.configuredTypes.value.indexOf(type), 1); } delete this.confidenceFilters.value[type]; + } + + deleteType(type: string): boolean { + this.deleteTypeConfiguration(type); this.markChangesPending({ action: 'meta' }); + return true; } setConfidenceFilters(val?: Record) { diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts index a281254e1..d313806a8 100644 --- a/client/src/CameraStore.ts +++ b/client/src/CameraStore.ts @@ -243,23 +243,6 @@ export default class CameraStore { }); } - changeTrackTypes({ currentType, newType }: { currentType: string; newType: string }) { - this.camMap.value.forEach((camera) => { - camera.trackStore.sorted.value.forEach((annotation) => { - for (let i = 0; i < annotation.confidencePairs.length; i += 1) { - const [name, confidenceVal] = annotation.confidencePairs[i]; - if (name === currentType) { - const track = camera.trackStore.get(annotation.id); - if (track) { - track.setType(newType, confidenceVal, currentType); - } - break; - } - } - }); - }); - } - removeTypes(id: AnnotationId, types: string[]) { let resultingTypes: ConfidencePair[] = []; this.camMap.value.forEach((camera) => { diff --git a/client/src/TrackFilterControls.spec.ts b/client/src/TrackFilterControls.spec.ts index a445370ca..603440db5 100644 --- a/client/src/TrackFilterControls.spec.ts +++ b/client/src/TrackFilterControls.spec.ts @@ -1,10 +1,29 @@ /// -import { nextTick } from 'vue'; +import { nextTick, ref } from 'vue'; import Track, { Feature } from './track'; import TrackFilterControls from './TrackFilterControls'; import GroupFilterControls from './GroupFilterControls'; +import type { MarkChangesPendingFilter } from './BaseFilterControls'; 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'; + +const apiMocks = vi.hoisted(() => ({ + saveConfig: vi.fn(), + saveDetections: vi.fn(), + saveAttributes: vi.fn(), + saveAttributeTrackFilters: vi.fn(), +})); + +vi.mock('dive-common/apispec', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useApi: () => apiMocks, + }; +}); const markChangesPending = () => null; @@ -67,7 +86,7 @@ function makeGroupFilterControls(store: CameraStore) { }); } -function makeTrackFilterControls() { +function makeTrackFilterControls(markPending: MarkChangesPendingFilter = markChangesPending) { const cameraStore = makeCameraStore(); const groupFilterControls = makeGroupFilterControls(cameraStore); const setTrackType = ( @@ -87,16 +106,265 @@ function makeTrackFilterControls() { return new TrackFilterControls({ sorted: cameraStore.sortedTracks, remove, - markChangesPending, + markChangesPending: markPending, groupFilterControls, lookupGroups: cameraStore.lookupGroups, getTrack: (track: AnnotationId, camera = 'singleCam') => (cameraStore.getTrack(track, camera)), + getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track), setType: setTrackType, removeTypes, }); } +function makePairFixture( + confidencePairs: [string, number][][], + markPending = vi.fn(), +) { + const cameraStore = new CameraStore({ markChangesPending: markPending }); + const trackStore = cameraStore.camMap.value.get('singleCam')?.trackStore; + confidencePairs.forEach((pairs, id) => { + trackStore?.insert(new Track(id, { confidencePairs: pairs, features })); + }); + trackStore?.setEnableSorting(); + const groupFilterControls = makeGroupFilterControls(cameraStore); + const filters = new TrackFilterControls({ + sorted: cameraStore.sortedTracks, + remove: (id) => cameraStore.removeTracks(id), + markChangesPending: markPending, + groupFilterControls, + lookupGroups: cameraStore.lookupGroups, + getTrack: (id, camera = 'singleCam') => cameraStore.getTrack(id, camera), + getTracks: (id) => cameraStore.getTrackAll(id), + setType: (id, type, confidence, current) => ( + cameraStore.setTrackType(id, type, confidence, current) + ), + removeTypes: (id, types) => cameraStore.removeTypes(id, types), + }); + return { cameraStore, filters, markPending }; +} + describe('useAnnotationFilters', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMocks.saveConfig.mockResolvedValue(undefined); + apiMocks.saveDetections.mockResolvedValue(undefined); + apiMocks.saveAttributes.mockResolvedValue(undefined); + apiMocks.saveAttributeTrackFilters.mockResolvedValue(undefined); + }); + + afterEach(() => { + clientSettings.typeSettings.preventCascadeTypes = false; + }); + + it('loads absent and valid hierarchy state without creating a save instruction', () => { + const tf = makeTrackFilterControls(); + tf.setTypeHierarchy(undefined); + expect(tf.hierarchyActive.value).toBe(false); + expect(tf.invalidHierarchyReason.value).toBeNull(); + expect(tf.consumeLoadWarning()).toBeNull(); + expect(tf.typeHierarchySavePatch()).toEqual({}); + + tf.setTypeHierarchy({ shark: 'fish', 'great white shark': 'shark' }); + expect(tf.hierarchyActive.value).toBe(true); + expect(tf.allTypes.value).toEqual([ + 'foo', 'bar', 'baz', 'great white shark', 'shark', 'fish', + ]); + expect(tf.checkedTypes.value).toEqual(expect.arrayContaining([ + 'great white shark', 'shark', 'fish', + ])); + expect(tf.typeHierarchySavePatch()).toEqual({}); + }); + + it('disables an invalid stored hierarchy and emits its load warning once', () => { + const tf = makeTrackFilterControls(); + tf.setTypeHierarchy({ fish: 'fish' }); + + expect(tf.hierarchyActive.value).toBe(false); + expect(tf.invalidHierarchyReason.value).toBe('self edge "fish -> fish"'); + expect(tf.typeHierarchySavePatch()).toEqual({}); + expect(tf.consumeLoadWarning()).toBe( + 'The saved type hierarchy is invalid: self edge "fish -> fish". ' + + 'Hierarchical type selection is disabled until the configuration is corrected.', + ); + expect(tf.consumeLoadWarning()).toBeNull(); + }); + + it('re-arms hierarchy load warnings and removes stale hierarchy-only choices on reset', () => { + const tf = makeTrackFilterControls(); + tf.setTypeHierarchy({ shark: 'fish' }); + expect(tf.checkedTypes.value).toEqual(expect.arrayContaining(['shark', 'fish'])); + + tf.setTypeHierarchy(undefined); + expect(tf.allTypes.value).toEqual(['foo', 'bar', 'baz']); + expect(tf.checkedTypes.value).not.toEqual(expect.arrayContaining(['shark', 'fish'])); + + tf.setTypeHierarchy({ fish: 'fish' }); + expect(tf.consumeLoadWarning()).not.toBeNull(); + }); + + it('retains a hierarchy save patch until persistence succeeds', () => { + const tf = makeTrackFilterControls(); + tf.setTypeHierarchy({ foo: 'root' }); + tf.updateTypeName({ currentType: 'root', newType: 'heading' }); + + const expected = { typeHierarchy: { foo: 'heading' } }; + expect(tf.typeHierarchySavePatch()).toEqual(expected); + expect(tf.typeHierarchySavePatch()).toEqual(expected); + tf.markTypeHierarchyPersisted(expected); + expect(tf.typeHierarchySavePatch()).toEqual({}); + }); + + it('keeps a hierarchy edit made while an earlier save was in flight', () => { + const tf = makeTrackFilterControls(); + tf.setTypeHierarchy({ foo: 'root' }); + tf.updateTypeName({ currentType: 'root', newType: 'heading' }); + + const inFlight = tf.typeHierarchySavePatch(); + tf.updateTypeName({ currentType: 'heading', newType: 'later' }); + tf.markTypeHierarchyPersisted(inFlight); + + expect(tf.typeHierarchySavePatch()).toEqual({ typeHierarchy: { foo: 'later' } }); + }); + + it('keeps a mid-flight hierarchy edit pending for the next config save', async () => { + const saveControls = useSave(ref('single-dataset'), ref(false)); + const tf = makeTrackFilterControls( + saveControls.markChangesPending as MarkChangesPendingFilter, + ); + tf.setTypeHierarchy({ foo: 'root' }); + tf.updateTypeName({ currentType: 'root', newType: 'heading' }); + + let resolveFirstSave = () => {}; + apiMocks.saveConfig + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFirstSave = resolve; + })) + .mockResolvedValue(undefined); + const firstPatch = tf.typeHierarchySavePatch(); + const firstSave = saveControls.save(firstPatch); + + tf.updateTypeName({ currentType: 'heading', newType: 'later' }); + resolveFirstSave(); + const firstResult = await firstSave; + if (firstResult.canonicalConfigPersisted) { + tf.markTypeHierarchyPersisted(firstPatch); + } + + const secondPatch = { typeHierarchy: { foo: 'later' } }; + expect(tf.typeHierarchySavePatch()).toEqual(secondPatch); + expect(saveControls.pendingSaveCount.value).toBe(1); + const hasUnsavedChanges = saveControls.pendingSaveCount.value > 0; + expect(hasUnsavedChanges).toBe(true); + + const secondResult = await saveControls.save(tf.typeHierarchySavePatch()); + if (secondResult.canonicalConfigPersisted) { + tf.markTypeHierarchyPersisted(secondPatch); + } + expect(apiMocks.saveConfig.mock.calls).toEqual([ + ['single-dataset', firstPatch], + ['single-dataset', secondPatch], + ]); + expect(saveControls.pendingSaveCount.value).toBe(0); + expect(tf.typeHierarchySavePatch()).toEqual({}); + }); + + it('does not acknowledge a dirty hierarchy during a detections-only save', async () => { + const saveControls = useSave(ref('single-dataset'), ref(false)); + const tf = makeTrackFilterControls(); + tf.setTypeHierarchy({ foo: 'root' }); + tf.updateTypeName({ currentType: 'root', newType: 'heading' }); + const patch = tf.typeHierarchySavePatch(); + saveControls.markChangesPending({ + action: 'upsert', + track: new Track(99, { confidencePairs: [['foo', 1]], features }), + }); + + const result = await saveControls.save(patch); + if (result.canonicalConfigPersisted) { + tf.markTypeHierarchyPersisted(patch); + } + + expect(result.canonicalConfigPersisted).toBe(false); + expect(apiMocks.saveConfig).not.toHaveBeenCalled(); + expect(tf.typeHierarchySavePatch()).toEqual(patch); + }); + + it('reports a completed hierarchy write when a parallel detection save fails', async () => { + const saveControls = useSave(ref('single-dataset'), ref(false)); + const tf = makeTrackFilterControls( + saveControls.markChangesPending as MarkChangesPendingFilter, + ); + tf.setTypeHierarchy({ foo: 'root' }); + tf.updateTypeName({ currentType: 'root', newType: 'heading' }); + const patch = tf.typeHierarchySavePatch(); + saveControls.markChangesPending({ + action: 'upsert', + track: new Track(99, { confidencePairs: [['foo', 1]], features }), + }); + apiMocks.saveDetections.mockRejectedValueOnce(new Error('detection save failed')); + + const error = await saveControls.save(patch).catch((reason) => reason); + if (error.canonicalConfigPersisted) { + tf.markTypeHierarchyPersisted(patch); + } + + expect(error.canonicalConfigPersisted).toBe(true); + expect(tf.typeHierarchySavePatch()).toEqual({}); + }); + + it('retries every multicamera hierarchy target after the parent save fails', async () => { + const datasetId = 'multicam-dataset'; + const saveControls = useSave(ref(datasetId), ref(false)); + saveControls.removeCamera('singleCam'); + saveControls.addCamera('left'); + saveControls.addCamera('right'); + const tf = makeTrackFilterControls( + saveControls.markChangesPending as MarkChangesPendingFilter, + ); + tf.setTypeHierarchy({ foo: 'root' }); + tf.updateTypeName({ currentType: 'root', newType: 'heading' }); + const expected = { typeHierarchy: { foo: 'heading' } }; + expect(saveControls.pendingSaveCount.value).toBe(1); + let rejectParent = true; + apiMocks.saveConfig.mockImplementation(async (id: string) => { + if (id === datasetId && rejectParent) { + rejectParent = false; + throw new Error('parent save failed'); + } + }); + + const firstPatch = tf.typeHierarchySavePatch(); + expect(saveControls.pendingSaveCount.value).toBe(1); + await expect(saveControls.save(firstPatch)).rejects.toThrow('parent save failed'); + expect(tf.typeHierarchySavePatch()).toEqual(expected); + + const retryPatch = tf.typeHierarchySavePatch(); + expect(retryPatch).toEqual(firstPatch); + expect(saveControls.pendingSaveCount.value).toBe(1); + await saveControls.save(retryPatch); + expect(apiMocks.saveConfig.mock.calls).toEqual([ + [`${datasetId}/left`, {}], + [`${datasetId}/right`, {}], + [datasetId, expected], + [datasetId, expected], + ]); + + tf.markTypeHierarchyPersisted(retryPatch); + expect(saveControls.pendingSaveCount.value).toBe(0); + expect(tf.typeHierarchySavePatch()).toEqual({}); + }); + + it('accepts corrected replacement and clear loads after invalid storage', () => { + const tf = makeTrackFilterControls(); + tf.setTypeHierarchy({ fish: 'fish' }); + tf.setTypeHierarchy({ shark: 'fish' }); + expect(tf.hierarchyActive.value).toBe(true); + expect(tf.invalidHierarchyReason.value).toBeNull(); + tf.setTypeHierarchy({}); + expect(tf.hierarchyActive.value).toBe(false); + expect(tf.typeHierarchySavePatch()).toEqual({}); + }); + it('updateTypeName', async () => { const tf = makeTrackFilterControls(); tf.setConfidenceFilters({ baz: 0.1, bar: 0.2, default: 0.1 }); @@ -126,4 +394,321 @@ describe('useAnnotationFilters', () => { tf.removeTypeAnnotations(['baz']); expect(tf.allTypes.value).toEqual(['foo', 'bar', 'baz']); }); + + it('returns the caller fallback without recomputing flat pair selection', () => { + const { cameraStore, filters } = makePairFixture([ + [['root', 0.1], ['leaf', 0.9]], + ]); + const track = cameraStore.getTrack(0); + filters.checkedTypes.value = []; + filters.setConfidenceFilters({ default: 1 }); + filters.disableAnnotationFilters.value = true; + expect(filters.displayPairIndex(track, 1)).toBe(1); + expect(filters.displayPairIndex(track, -1)).toBe(-1); + }); + + it('preserves the complete flat filter matrix', () => { + const { filters } = makePairFixture([ + [['top', 0.5], ['fallback', 0.8]], + [], + ]); + filters.setConfidenceFilters({ top: 0.5, fallback: 0.8, default: 0.1 }); + filters.checkedTypes.value = ['top', 'fallback']; + expect(filters.filteredAnnotations.value.map(({ context }) => context.confidencePairIndex)) + .toEqual([0, -1]); + + filters.checkedTypes.value = ['fallback']; + expect(filters.filteredAnnotations.value.map(({ context }) => context.confidencePairIndex)) + .toEqual([1, -1]); + + const cascadeFixture = makePairFixture([ + [['top', 0.5], ['fallback', 0.8]], + ]).filters; + cascadeFixture.setConfidenceFilters({ top: 0.5, fallback: 0.8, default: 0.1 }); + cascadeFixture.checkedTypes.value = ['top', 'fallback']; + clientSettings.typeSettings.preventCascadeTypes = true; + expect(cascadeFixture.filteredAnnotations.value).toHaveLength(0); + cascadeFixture.setConfidenceFilters({ top: 0.49, fallback: 0.8, default: 0.1 }); + expect(cascadeFixture.filteredAnnotations.value.map(({ context }) => context.confidencePairIndex)) + .toEqual([0]); + + cascadeFixture.disableAnnotationFilters.value = true; + expect(cascadeFixture.filteredAnnotations.value.map(({ context }) => context.confidencePairIndex)) + .toEqual([0]); + }); + + it('selects deepest qualifying hierarchy pairs for monotone and non-monotone scores', () => { + const { cameraStore, filters } = makePairFixture([ + [['root', 0.9], ['child', 0.8], ['leaf', 0.7]], + [['root', 0.2], ['child', 0.9], ['leaf', 0.6]], + ]); + filters.setTypeHierarchy({ leaf: 'child', child: 'root' }); + filters.setConfidenceFilters({ default: 0.5 }); + expect(filters.displayPairIndex(cameraStore.getTrack(0), 0)).toBe(2); + expect(filters.displayPairIndex(cameraStore.getTrack(1), 0)).toBe(2); + + filters.setConfidenceFilters({ leaf: 0.7, default: 0.5 }); + expect(filters.displayPairIndex(cameraStore.getTrack(0), 0)).toBe(2); + filters.setConfidenceFilters({ leaf: 0.71, default: 0.5 }); + expect(filters.displayPairIndex(cameraStore.getTrack(0), 0)).toBe(1); + }); + + it('rolls up unchecked leaves and ignores Prevent Cascade in hierarchy mode', () => { + const { cameraStore, filters } = makePairFixture([ + [['root', 0.9], ['child', 0.8], ['leaf', 0.7]], + ]); + filters.setTypeHierarchy({ leaf: 'child', child: 'root' }); + filters.checkedTypes.value = ['root', 'child']; + clientSettings.typeSettings.preventCascadeTypes = false; + const withoutPrevent = filters.displayPairIndex(cameraStore.getTrack(0), 0); + clientSettings.typeSettings.preventCascadeTypes = true; + expect(filters.displayPairIndex(cameraStore.getTrack(0), 0)).toBe(withoutPrevent); + expect(withoutPrevent).toBe(1); + }); + + 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' }); + filters.checkedTypes.value = []; + filters.disableAnnotationFilters.value = true; + expect(filters.displayPairIndex(cameraStore.getTrack(0), -1)).toBe(0); + }); + + it('excludes empty and entirely non-passing hierarchy vectors without inventing a pair', () => { + const { cameraStore, filters } = makePairFixture([[], [['root', 0.1]]]); + filters.setTypeHierarchy({ leaf: 'root' }); + filters.setConfidenceFilters({ default: 0.5 }); + expect(filters.displayPairIndex(cameraStore.getTrack(0), 0)).toBe(-1); + expect(filters.displayPairIndex(cameraStore.getTrack(1), 0)).toBe(-1); + expect(filters.filteredAnnotations.value).toEqual([]); + + filters.disableAnnotationFilters.value = true; + expect(filters.displayPairIndex(cameraStore.getTrack(0), 0)).toBe(-1); + expect(filters.filteredAnnotations.value.map(({ annotation, context }) => ({ + id: annotation.id, + confidencePairIndex: context.confidencePairIndex, + }))).toEqual([{ id: 1, confidencePairIndex: 0 }]); + }); + + it('compiles only when hierarchy content changes', () => { + const { filters } = makePairFixture([[['root', 1]]]); + filters.setTypeHierarchy({ leaf: 'root' }); + const firstIndex = filters.hierarchyIndex.value; + filters.setConfidenceFilters({ default: 0.9 }); + filters.checkedTypes.value = ['root']; + expect(filters.hierarchyIndex.value).toBe(firstIndex); + filters.setTypeHierarchy({ leaf: 'root' }); + expect(filters.hierarchyIndex.value).toBe(firstIndex); + filters.setTypeHierarchy({ leaf: 'root', fin: 'root' }); + expect(filters.hierarchyIndex.value).not.toBe(firstIndex); + }); + + it('keeps hierarchy-only members out of configured style persistence', () => { + const { filters } = makePairFixture([[['leaf', 1]]]); + filters.importTypes(['configured'], false); + filters.setTypeHierarchy({ leaf: 'heading' }); + expect(filters.allTypes.value).toEqual(['leaf', 'configured', 'heading']); + expect(filters.usedPlusConfiguredTypes.value).toEqual(['leaf', 'configured']); + expect(filters.checkedTypes.value).toEqual(expect.arrayContaining(['heading'])); + filters.setTypeHierarchy(undefined); + expect(filters.allTypes.value).toEqual(['leaf', 'configured']); + expect(filters.checkedTypes.value).not.toContain('heading'); + }); + + it('does not recheck an unchecked hierarchy member when style promotion configures it', () => { + const { filters } = makePairFixture([[['leaf', 1]]]); + filters.setTypeHierarchy({ leaf: 'heading' }); + filters.updateCheckedTypes(['leaf']); + filters.importTypes(['heading'], false); + expect(filters.configuredTypes.value).toContain('heading'); + expect(filters.checkedTypes.value).toEqual(['leaf']); + }); + + it('preserves an unchecked used type when hierarchy loading makes it a member', () => { + const { filters } = makePairFixture([[['leaf', 1]]]); + filters.updateCheckedTypes([]); + filters.setTypeHierarchy({ leaf: 'heading' }); + expect(filters.checkedTypes.value).toEqual(['heading']); + }); + + it('keeps generic group type imports checkbox-neutral', () => { + const cameraStore = makeCameraStore(); + const groupFilters = makeGroupFilterControls(cameraStore); + groupFilters.updateCheckedTypes([]); + groupFilters.importTypes(['unused group'], false); + expect(groupFilters.allTypes.value).toContain('unused group'); + expect(groupFilters.checkedTypes.value).toEqual([]); + }); + + it('preserves flat track and group configured-only rename behavior', () => { + const { filters } = makePairFixture([[['used', 0.8]]]); + filters.importTypes(['unused'], false); + filters.updateTypeName({ currentType: 'unused', newType: 'renamed' }); + expect(filters.configuredTypes.value).not.toContain('unused'); + expect(filters.configuredTypes.value).not.toContain('renamed'); + + const cameraStore = makeCameraStore(); + const groupFilters = makeGroupFilterControls(cameraStore); + groupFilters.importTypes(['unused group'], false); + groupFilters.updateTypeName({ currentType: 'unused group', newType: 'renamed group' }); + expect(groupFilters.configuredTypes.value).not.toContain('unused group'); + expect(groupFilters.configuredTypes.value).not.toContain('renamed group'); + }); + + it('rewrites hierarchy, annotations, configured types, filters, and checks on rename', () => { + const markPending = vi.fn(); + const { cameraStore, filters } = makePairFixture([[['leaf', 0.8], ['root', 0.7]]], markPending); + filters.importTypes(['leaf'], false); + filters.setConfidenceFilters({ leaf: 0.4, default: 0.1 }); + filters.setTypeHierarchy({ leaf: 'root' }); + markPending.mockClear(); + filters.updateTypeName({ currentType: 'leaf', newType: 'fin' }); + expect(filters.typeHierarchy.value).toEqual({ fin: 'root' }); + expect(cameraStore.getTrack(0).confidencePairs).toEqual([['fin', 0.8], ['root', 0.7]]); + expect(filters.configuredTypes.value).toEqual(['fin']); + expect(filters.confidenceFilters.value).toEqual({ fin: 0.4, default: 0.1 }); + expect(filters.checkedTypes.value).toContain('fin'); + expect(filters.typeHierarchySavePatch()).toEqual({ typeHierarchy: { fin: 'root' } }); + }); + + it('does not configure a hierarchy-only heading during a name-only rename', () => { + const { filters } = makePairFixture([[['leaf', 1]]]); + filters.setTypeHierarchy({ leaf: 'heading' }); + filters.updateTypeName({ currentType: 'heading', newType: 'renamed heading' }); + expect(filters.typeHierarchy.value).toEqual({ leaf: 'renamed heading' }); + expect(filters.allTypes.value).toEqual(['leaf', 'renamed heading']); + expect(filters.usedPlusConfiguredTypes.value).toEqual(['leaf']); + }); + + it('renames without collapsing, reordering, or rescoring a confidence-1 vector', () => { + const { cameraStore, filters } = makePairFixture([ + [['leaf', 1], ['root', 0.8], ['other', 0.2]], + ]); + filters.setTypeHierarchy({ leaf: 'root' }); + filters.updateTypeName({ currentType: 'leaf', newType: 'fin' }); + expect(cameraStore.getTrack(0).confidencePairs).toEqual([ + ['fin', 1], ['root', 0.8], ['other', 0.2], + ]); + }); + + it('preserves each camera confidence vector while renaming exact occurrences', () => { + const { cameraStore, filters } = makePairFixture([ + [['leaf', 1], ['root', 0.8]], + ]); + cameraStore.addCamera('right'); + cameraStore.camMap.value.get('right')?.trackStore.insert(new Track(0, { + confidencePairs: [['other', 0.6], ['leaf', 0.4]], + features, + })); + filters.setTypeHierarchy({ leaf: 'root' }); + filters.updateTypeName({ currentType: 'leaf', newType: 'fin' }); + expect(cameraStore.getTrack(0, 'singleCam').confidencePairs).toEqual([ + ['fin', 1], ['root', 0.8], + ]); + expect(cameraStore.getTrack(0, 'right').confidencePairs).toEqual([ + ['other', 0.6], ['fin', 0.4], + ]); + }); + + it('rejects invalid hierarchy renames before any mutation or pending event', () => { + const markPending = vi.fn(); + const { cameraStore, filters } = makePairFixture([[['leaf', 0.8]]], markPending); + filters.setTypeHierarchy({ leaf: 'root' }); + markPending.mockClear(); + expect(() => filters.updateTypeName({ currentType: 'leaf', newType: 'root' })) + .toThrow(TypeHierarchyError); + expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' }); + expect(cameraStore.getTrack(0).confidencePairs).toEqual([['leaf', 0.8]]); + expect(markPending).not.toHaveBeenCalled(); + }); + + it('rejects a rename when one track already has both names', () => { + const markPending = vi.fn(); + const { cameraStore, filters } = makePairFixture([ + [['leaf', 0.8], ['fin', 0.7]], + ], markPending); + filters.setTypeHierarchy({ leaf: 'root' }); + markPending.mockClear(); + expect(() => filters.updateTypeName({ currentType: 'leaf', newType: 'fin' })) + .toThrow('track 0 already contains both "leaf" and "fin"'); + expect(cameraStore.getTrack(0).confidencePairs).toEqual([['leaf', 0.8], ['fin', 0.7]]); + expect(markPending).not.toHaveBeenCalled(); + }); + + it('rejects a collision in another camera before changing any stored vector', () => { + const markPending = vi.fn(); + const { cameraStore, filters } = makePairFixture([ + [['leaf', 1], ['root', 0.8]], + ], markPending); + cameraStore.addCamera('right'); + cameraStore.camMap.value.get('right')?.trackStore.insert(new Track(0, { + confidencePairs: [['leaf', 0.6], ['fin', 0.5]], + features, + })); + filters.setTypeHierarchy({ leaf: 'root' }); + markPending.mockClear(); + + expect(() => filters.updateTypeName({ currentType: 'leaf', newType: 'fin' })) + .toThrow('track 0 already contains both "leaf" and "fin"'); + expect(cameraStore.getTrack(0, 'singleCam').confidencePairs).toEqual([ + ['leaf', 1], ['root', 0.8], + ]); + expect(cameraStore.getTrack(0, 'right').confidencePairs).toEqual([ + ['leaf', 0.6], ['fin', 0.5], + ]); + expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' }); + expect(markPending).not.toHaveBeenCalled(); + }); + + it('clears settings for unused parents and leaves hierarchy state unchanged', () => { + const markPending = vi.fn(); + const { filters } = makePairFixture([[['used', 1]]], markPending); + filters.importTypes(['leaf'], false); + filters.setConfidenceFilters({ leaf: 0.4, default: 0.1 }); + filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' }); + markPending.mockClear(); + const hierarchyBefore = { ...filters.typeHierarchy.value }; + const checkedBefore = [...filters.checkedTypes.value]; + expect(filters.deleteType('parent')).toBe(true); + expect(filters.deleteType('leaf')).toBe(true); + expect(filters.typeHierarchy.value).toEqual(hierarchyBefore); + expect(filters.configuredTypes.value).not.toContain('leaf'); + expect(filters.confidenceFilters.value).not.toHaveProperty('leaf'); + expect(filters.checkedTypes.value).toEqual(checkedBefore); + expect(markPending).toHaveBeenCalledTimes(2); + }); + + it('blocks deleting a type that only a collapse-hidden camera still uses', () => { + const markPending = vi.fn(); + const { cameraStore, filters } = makePairFixture([ + [['fish', 0.9], ['tuna', 0.7]], + ], markPending); + cameraStore.addCamera('right'); + cameraStore.camMap.value.get('right')?.trackStore.insert(new Track(0, { + confidencePairs: [['shark', 1]], + features, + })); + filters.importTypes(['tuna'], false); + filters.setConfidenceFilters({ tuna: 0.4, default: 0.1 }); + filters.setTypeHierarchy({ tuna: 'fish' }); + markPending.mockClear(); + + expect(filters.usedTypes.value).toEqual(['shark']); + expect(filters.typeInUseOnAnyCamera('tuna')).toBe(true); + expect(filters.deleteType('tuna')).toBe(false); + expect(filters.typeHierarchy.value).toEqual({ tuna: 'fish' }); + expect(filters.configuredTypes.value).toContain('tuna'); + expect(filters.confidenceFilters.value).toHaveProperty('tuna', 0.4); + expect(filters.checkedTypes.value).toContain('tuna'); + expect(markPending).not.toHaveBeenCalled(); + }); + + it('keeps hierarchy active after clearing the final leaf settings', () => { + const { filters } = makePairFixture([[['used', 1]]]); + filters.setTypeHierarchy({ leaf: 'root' }); + expect(filters.deleteType('leaf')).toBe(true); + expect(filters.hierarchyActive.value).toBe(true); + expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' }); + expect(filters.typeHierarchySavePatch()).toEqual({}); + }); }); diff --git a/client/src/TrackFilterControls.ts b/client/src/TrackFilterControls.ts index 5d2b8d512..893722f7d 100644 --- a/client/src/TrackFilterControls.ts +++ b/client/src/TrackFilterControls.ts @@ -1,16 +1,30 @@ import { computed, Ref, ref } from 'vue'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import { clientSettings } from 'dive-common/store/settings'; +import { + compileHierarchy, + normalizeTypeHierarchy, + rewriteHierarchyType, + selectPairIndex, + TypeHierarchy, + TypeHierarchyError, + TypeHierarchyIndex, +} from 'dive-common/typeHierarchy'; import { AnnotationId } from './BaseAnnotation'; import BaseFilterControls, { AnnotationWithContext, FilterControlsParams } from './BaseFilterControls'; import type Group from './Group'; import type Track from './track'; import { AttributeTrackFilter, trackIdPassesFilter, userDefinedVals } from './AttributeTrackFilterControls'; +export interface TypeHierarchySavePatch { + typeHierarchy?: Record | null; +} + interface TrackFilterControlsParams extends FilterControlsParams { lookupGroups: (annotationId: AnnotationId) => Group[]; getTrack: (annotationId: AnnotationId, camera?: string) => Track; groupFilterControls: BaseFilterControls; + getTracks: (annotationId: AnnotationId) => Track[]; } export default class TrackFilterControls extends BaseFilterControls { @@ -22,9 +36,47 @@ export default class TrackFilterControls extends BaseFilterControls { enabledFilters: Ref; + typeHierarchy: Ref; + + hierarchyIndex: Ref; + + hierarchyActive: Ref; + + hierarchyMembers: Ref; + + invalidHierarchyReason: Ref; + + private hierarchyWarningConsumed = false; + + private hierarchyDirty = false; + + private checkedTypesSet = computed(() => new Set(this.checkedTypes.value)); + + private getTracks: (annotationId: AnnotationId) => Track[]; + constructor(params: TrackFilterControlsParams) { super(params); + this.getTracks = params.getTracks; + + const flatAllTypes = this.allTypes; + this.typeHierarchy = ref(undefined); + this.hierarchyIndex = ref(undefined); + this.invalidHierarchyReason = ref(null); + this.hierarchyMembers = computed(() => { + const members = new Set(); + Object.entries(this.typeHierarchy.value || {}).forEach(([child, parent]) => { + members.add(child); + members.add(parent); + }); + return Array.from(members); + }); + this.hierarchyActive = computed(() => this.hierarchyIndex.value !== undefined); + this.allTypes = computed(() => Array.from(new Set([ + ...flatAllTypes.value, + ...this.hierarchyMembers.value, + ]))); + this.attributeFilters = ref([]); this.userDefinedValues = ref([]); @@ -36,7 +88,7 @@ export default class TrackFilterControls extends BaseFilterControls { * for filtering based on group membership as well */ this.filteredAnnotations = computed(() => { - const checkedSet = new Set(this.checkedTypes.value); + const checkedSet = this.checkedTypesSet.value; const filteredGroupsSet = new Set(params.groupFilterControls.enabledAnnotations.value .map((v) => v.annotation.id)); const confidenceFiltersVal = cloneDeep(this.confidenceFilters.value); @@ -58,33 +110,42 @@ export default class TrackFilterControls extends BaseFilterControls { */ enabledInGroupFilters = groups.some((group) => filteredGroupsSet.has(group.id)); } - let confidencePairIndex = annotation.confidencePairs - .findIndex(([confkey, confval]) => { + let confidencePairIndex: number; + if (this.hierarchyActive.value) { + confidencePairIndex = this.displayPairIndex( + annotation as unknown as Readonly, + -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, ); - 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) { + if (checkedSet.has(confkey) && confval > confidenceThresh) { + confidencePairIndex = 0; + } else { + confidencePairIndex = -1; + } + } + if (this.disableAnnotationFilters.value) { confidencePairIndex = 0; - } else { - confidencePairIndex = -1; } } - if (this.disableAnnotationFilters.value) { - confidencePairIndex = 0; - } /* include annotations where at least 1 confidence pair is above * the threshold and part of the checked type set */ if ( - (confidencePairIndex >= 0 || annotation.confidencePairs.length === 0) + (confidencePairIndex >= 0 + || (!this.hierarchyActive.value && annotation.confidencePairs.length === 0)) && enabledInGroupFilters && !resultsIds.has(annotation.id) ) { let addValue = true; @@ -113,6 +174,201 @@ export default class TrackFilterControls extends BaseFilterControls { }); } + displayPairIndex(track: Readonly, flatFallbackIndex: number): number { + const index = this.hierarchyIndex.value; + if (index === undefined) { + return flatFallbackIndex; + } + if (track.confidencePairs.length === 0) { + return -1; + } + if (this.disableAnnotationFilters.value) { + return 0; + } + const checkedSet = this.checkedTypesSet.value; + const confidenceFilters = this.confidenceFilters.value; + const passes = track.confidencePairs.map(([confkey, confval]) => { + const confidenceThresh = Math.max( + confidenceFilters[confkey] || 0, + confidenceFilters.default, + ); + return confval >= confidenceThresh && checkedSet.has(confkey); + }); + return selectPairIndex(index, track.confidencePairs, passes); + } + + private installTypeHierarchy(value: unknown, dirty: boolean) { + const previousTypes = new Set(this.allTypes.value); + let normalized: TypeHierarchy | undefined; + try { + normalized = normalizeTypeHierarchy(value === undefined ? null : value); + this.invalidHierarchyReason.value = null; + } catch (error) { + if (!(error instanceof TypeHierarchyError)) { + throw error; + } + if (dirty) { + throw error; + } + normalized = undefined; + this.invalidHierarchyReason.value = error.reason; + } + + const current = this.typeHierarchy.value; + const changed = !isEqual(current, normalized); + this.typeHierarchy.value = normalized; + if (changed) { + this.hierarchyIndex.value = normalized ? compileHierarchy(normalized) : undefined; + } + + const nextMembers = new Set(this.hierarchyMembers.value); + const baseline = new Set(this.usedPlusConfiguredTypes.value); + const checked = this.checkedTypes.value.filter( + (name) => baseline.has(name) || nextMembers.has(name), + ); + nextMembers.forEach((name) => { + if (!previousTypes.has(name) && !checked.includes(name)) { + checked.push(name); + } + }); + this.checkedTypes.value = checked; + + this.hierarchyDirty = dirty; + } + + /** Install hierarchy state loaded from a dataset or a successful config replacement. */ + setTypeHierarchy(value: unknown) { + this.hierarchyWarningConsumed = false; + this.installTypeHierarchy(value, false); + } + + /** Usage across every camera's stored vector, unlike the lossy merged `usedTypes`. */ + typeInUseOnAnyCamera(type: string): boolean { + return this.sorted.value.some((annotation) => this.getTracks(annotation.id) + .some((track) => track.confidencePairs.some(([name]) => name === type))); + } + + updateTypeName({ currentType, newType }: { currentType: string; newType: string }) { + if (!this.hierarchyActive.value) { + super.updateTypeName({ currentType, newType }); + // The base pass walks the merged view, whose confidence vector can hide a type that an + // individual camera still carries. Rename those per-camera tracks too. + this.sorted.value.forEach((annotation) => { + this.getTracks(annotation.id).forEach((track) => { + const pair = track.confidencePairs.find(([name]) => name === currentType); + if (pair) { + track.setType(newType, pair[1], currentType); + } + }); + }); + return; + } + const tracks = this.sorted.value.flatMap((annotation) => this.getTracks(annotation.id)); + const collision = tracks.find((track) => { + const names = new Set(track.confidencePairs.map(([name]) => name)); + return names.has(currentType) && names.has(newType); + }); + if (collision) { + throw new TypeHierarchyError( + `track ${collision.id} already contains both "${currentType}" and "${newType}"`, + 'conflict', + ); + } + + const currentHierarchy = this.typeHierarchy.value as TypeHierarchy; + const rewritten = rewriteHierarchyType(currentHierarchy, currentType, newType); + const hierarchyChanged = !isEqual(currentHierarchy, rewritten); + const currentWasChecked = this.checkedTypes.value.includes(currentType); + const newWasChecked = this.checkedTypes.value.includes(newType); + + this.sorted.value.forEach((annotation) => { + const storedTracks = this.getTracks(annotation.id); + const rewrittenPairs = storedTracks.map((track) => track.confidencePairs.map( + ([name, confidence]) => [ + name === currentType ? newType : name, + confidence, + ] as [string, number], + )); + const triggerPair = storedTracks + .flatMap((track) => track.confidencePairs) + .find(([name]) => name === currentType); + if (triggerPair) { + this.setType(annotation.id, newType, triggerPair[1], currentType); + storedTracks.forEach((track, index) => { + // setType emits the existing annotation notification. Restore the exact + // preflighted vector because its confidence-1 branch intentionally collapses pairs. + // eslint-disable-next-line no-param-reassign + track.confidencePairs = rewrittenPairs[index]; + }); + } + }); + if (!(newType in this.confidenceFilters.value) + && currentType in this.confidenceFilters.value) { + this.setConfidenceFilters({ + ...this.confidenceFilters.value, + [newType]: this.confidenceFilters.value[currentType], + }); + } + if (this.configuredTypes.value.includes(currentType) + && !this.configuredTypes.value.includes(newType)) { + this.configuredTypes.value.push(newType); + } + this.deleteTypeConfiguration(currentType); + if (hierarchyChanged) { + this.installTypeHierarchy(rewritten, true); + } + + const checked = new Set(this.checkedTypes.value); + if (!currentWasChecked && !newWasChecked) { + checked.delete(newType); + } else if (currentWasChecked) { + checked.add(newType); + } + if (!this.allTypes.value.includes(currentType)) { + checked.delete(currentType); + } + this.checkedTypes.value = Array.from(checked); + this.markChangesPending({ action: 'meta' }); + } + + deleteType(type: string): boolean { + if (!this.hierarchyActive.value) { + return super.deleteType(type); + } + if (this.typeInUseOnAnyCamera(type)) { + return false; + } + this.deleteTypeConfiguration(type); + this.markChangesPending({ action: 'meta' }); + return true; + } + + consumeLoadWarning(): string | null { + if (this.invalidHierarchyReason.value === null || this.hierarchyWarningConsumed) { + return null; + } + this.hierarchyWarningConsumed = true; + return `The saved type hierarchy is invalid: ${this.invalidHierarchyReason.value}. Hierarchical type selection is disabled until the configuration is corrected.`; + } + + typeHierarchySavePatch(): TypeHierarchySavePatch { + if (!this.hierarchyDirty) { + return {}; + } + if (this.typeHierarchy.value === undefined) { + return { typeHierarchy: null }; + } + return { typeHierarchy: { ...(this.typeHierarchy.value || {}) } }; + } + + // A save is asynchronous, so the hierarchy can be edited again while one is in flight. Only + // the state that was actually sent may be acknowledged; anything newer stays dirty. + markTypeHierarchyPersisted(persisted: TypeHierarchySavePatch) { + if (isEqual(this.typeHierarchySavePatch(), persisted)) { + this.hierarchyDirty = false; + } + } + loadTrackAttributesFilter(trackAttributesFilter: Readonly) { this.attributeFilters.value = []; this.userDefinedValues.value = []; diff --git a/client/src/components/FilterList.spec.ts b/client/src/components/FilterList.spec.ts new file mode 100644 index 000000000..14ce75d3d --- /dev/null +++ b/client/src/components/FilterList.spec.ts @@ -0,0 +1,151 @@ +// @vitest-environment jsdom +/// +import { + defineComponent, h, nextTick, ref, reactive, +} from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { clientSettings } from 'dive-common/store/settings'; +import FilterList from './FilterList.vue'; + +vi.mock('dive-common/vue-utilities/prompt-service', () => ({ + usePrompt: () => ({ prompt: vi.fn(), visible: () => false }), +})); + +/** + * `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` + * SFC is not, so the list is rendered from a host that captures the real instance. It is left + * unstubbed to keep shallow semantics for its own children, and the host stands in for + * `setProps`. + */ +function mountFilterList(props: Record) { + const state = reactive(props); + let child: InstanceType | undefined; + const Host = defineComponent({ + setup: () => () => h(FilterList, { + props: state, + ref: (instance) => { + if (instance && !(instance instanceof Element)) { + child = instance as InstanceType; + } + }, + }), + }); + const wrapper = shallowMount(Host, { stubs: { FilterList: false } }); + if (!child) { + throw new Error('FilterList did not mount'); + } + const setProps = async (next: Record) => { + Object.assign(state, next); + await nextTick(); + }; + return { wrapper, vm: child, setProps }; +} + +vi.mock('../provides', () => ({ + useCameraStore: () => ({ + camMap: ref(new Map([['singleCam', { + trackStore: { + annotationMap: new Map(), + intervalTree: { search: () => [] }, + getPossible: () => undefined, + }, + }]])), + getAnyPossibleTrack: () => undefined, + }), + useHandler: () => ({ seekFrame: vi.fn() }), + useReadOnlyMode: () => ref(false), + useSelectedCamera: () => ref('singleCam'), + useTime: () => ({ frame: ref(0) }), + usePendingSaveCount: () => ref(0), +})); + +describe('FilterList hierarchy members', () => { + it('keeps members as ordinary, independently checked flat rows', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = ''; + const checkedTypes = ref(['leaf', 'heading']); + const filterControls = Object.freeze({ + allTypes: ref(['leaf', 'heading']), + usedTypes: ref(['leaf']), + checkedTypes, + filteredAnnotations: ref([]), + confidenceFilters: ref({ default: 0.1 }), + disableAnnotationFilters: ref(false), + updateCheckedTypes: (types: string[]) => { checkedTypes.value = types; }, + removeTypeAnnotations: vi.fn(), + }); + const styleManager = Object.freeze({ + typeStyling: ref({ + color: () => '#fff', + strokeWidth: () => 1, + fill: () => false, + opacity: () => 1, + }), + }); + const { vm, setProps } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + expect(vm.visibleTypes).toEqual(['leaf']); + expect(vm.virtualHeight).toBe(160); + + await setProps({ showEmptyTypes: true }); + expect(vm.visibleTypes).toEqual(['heading', 'leaf']); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['heading', 'leaf']); + vm.updateCheckedType(false, 'heading'); + expect(checkedTypes.value).toEqual(['leaf']); + expect(vm.virtualTypes.find(({ type }) => type === 'heading')?.checked).toBe(false); + }); + + it('counts the type selected by each filtered annotation context', () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = ''; + const confidencePairs: [string, number][] = [['root', 0.9], ['leaf', 0.7]]; + const filterControls = Object.freeze({ + allTypes: ref(['root', 'leaf']), + usedTypes: ref(['root', 'leaf']), + checkedTypes: ref(['root', 'leaf']), + filteredAnnotations: ref([{ + annotation: { + id: 1, + begin: 0, + end: 1, + confidencePairs, + getType: (index = 0) => confidencePairs[index][0], + }, + context: { confidencePairIndex: 1 }, + }]), + confidenceFilters: ref({ default: 0.1 }), + disableAnnotationFilters: ref(false), + updateCheckedTypes: vi.fn(), + removeTypeAnnotations: vi.fn(), + }); + const styleManager = Object.freeze({ + typeStyling: ref({ + color: (type: string) => `color:${type}`, + strokeWidth: () => 1, + fill: () => false, + opacity: () => 1, + }), + }); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: true, + height: 240, + headerHeight: 80, + }); + + expect(vm.virtualTypes.find(({ type }) => type === 'leaf')).toEqual(expect.objectContaining({ + displayText: '1 : 0\u00A0 leaf', + color: 'color:leaf', + })); + expect(vm.virtualTypes.find(({ type }) => type === 'root')?.displayText) + .toBe('0 : 0\u00A0 root'); + }); +}); diff --git a/client/src/components/LayerManager.spec.ts b/client/src/components/LayerManager.spec.ts new file mode 100644 index 000000000..55fc360fa --- /dev/null +++ b/client/src/components/LayerManager.spec.ts @@ -0,0 +1,355 @@ +// @vitest-environment jsdom +/// +/* eslint-disable max-classes-per-file -- lightweight layer doubles */ +import { + defineComponent, h, ref, +} from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Track, { Feature } from '../track'; +import CameraStore from '../CameraStore'; +import TrackFilterControls from '../TrackFilterControls'; +import GroupFilterControls from '../GroupFilterControls'; +import type { AnnotationId } from '../BaseAnnotation'; +import LayerManager from './LayerManager.vue'; + +const layerMocks = vi.hoisted(() => { + const rectangleChangeData = vi.fn(); + + class MockLayer { + bus = { $on: vi.fn() }; + + featureLayer = {}; + + changeData = vi.fn(); + + disable = vi.fn(); + + setHoverAnnotations = vi.fn(); + + setClickTargetsOnly = vi.fn(); + + setDrawingOther = vi.fn(); + + updateSettings = vi.fn(); + + updateRenderAttributes = vi.fn(); + + setType = vi.fn(); + + setKey = vi.fn(); + + getMode = vi.fn(() => 'disabled'); + + clear = vi.fn(); + + updatePoints = vi.fn(); + + update = vi.fn(); + + addDOMWidget = vi.fn(); + + setToolTipWidget = vi.fn(); + + setDisplayTransform = vi.fn(); + } + + class MockRectangleLayer extends MockLayer { + changeData = rectangleChangeData; + } + + return { MockLayer, MockRectangleLayer, rectangleChangeData }; +}); + +const provided = vi.hoisted(() => ({ + values: null as null | Record, +})); + +vi.mock('../layers/AnnotationLayers/RectangleLayer', () => ({ + default: layerMocks.MockRectangleLayer, +})); +vi.mock('../layers/AnnotationLayers/PolygonLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/AnnotationLayers/PointLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/AnnotationLayers/LineLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/AnnotationLayers/TailLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/AnnotationLayers/OverlapLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/AnnotationLayers/RegistrationKeypointLayer', () => ({ + default: layerMocks.MockLayer, +})); +vi.mock('../layers/EditAnnotationLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/LassoSelectionLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/AnnotationLayers/TextLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/AnnotationLayers/AttributeLayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/AnnotationLayers/AttributeBoxLayer', () => ({ + default: layerMocks.MockLayer, +})); +vi.mock('../layers/AnnotationLayers/SegmentationPointsLayer', () => ({ + default: layerMocks.MockLayer, +})); +vi.mock('../layers/UILayers/UILayer', () => ({ default: layerMocks.MockLayer })); +vi.mock('../layers/UILayers/ToolTipWidget.vue', () => ({ default: {} })); + +vi.mock('./annotators/useMediaController', () => ({ + injectAggregateController: () => provided.values?.aggregateController, +})); + +vi.mock('../provides', () => ({ + useHandler: () => provided.values?.handler, + useSelectedTrackId: () => provided.values?.selectedTrackId, + useTrackFilters: () => provided.values?.trackFilters, + useTrackStyleManager: () => provided.values?.trackStyleManager, + useEditingMode: () => provided.values?.editingMode, + useVisibleModes: () => provided.values?.visibleModes, + useSelectedKey: () => provided.values?.selectedKey, + useMultiSelectList: () => provided.values?.multiSelectList, + useAnnotatorPreferences: () => provided.values?.annotatorPreferences, + useGroupStyleManager: () => provided.values?.groupStyleManager, + useCameraStore: () => provided.values?.cameraStore, + useCameraRegistration: () => { throw new Error('not provided'); }, + useAlignedView: () => { throw new Error('not provided'); }, + useSelectedCamera: () => provided.values?.selectedCamera, + useAttributes: () => provided.values?.attributes, + useComparisonSets: () => provided.values?.comparisonSets, + useLassoModeContext: () => ({ setLassoDrawing: vi.fn() }), + useSegmentationPoints: () => provided.values?.segmentationPoints, + usePendingSaveCount: () => provided.values?.pendingSaveCount, +})); + +vi.mock('./layerManager/useLayerManagerAlignedView', () => ({ + default: () => ({ + alignedDisplayTransform: ref(null), + alignedDisplayInverse: ref(null), + mapDisplayPoint: (x: number, y: number) => ({ x, y }), + mapNativePoint: (x: number, y: number) => [x, y], + mapEditGeoJSONToNative: (value: unknown) => value, + featureToDisplay: (value: unknown) => value, + setupDisplayTransformWatches: vi.fn(), + }), +})); + +vi.mock('./layerManager/useSegmentationPointsLayer', () => ({ default: vi.fn() })); +vi.mock('./layerManager/useAnnotationClickHandling', () => ({ + default: () => ({ wireHandlers: vi.fn() }), +})); + +/** + * `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` + * SFC is not, so the manager renders from a host. It stays unstubbed to keep shallow + * semantics for its own children. + */ +function mountLayerManager(props: Record = {}) { + const Host = defineComponent({ + setup: () => () => h(LayerManager, { props }), + }); + return shallowMount(Host, { stubs: { LayerManager: false } }); +} + +describe('LayerManager hierarchy frame data', () => { + it('drops a track whose own vector selects no pair without hiding valid frame data', () => { + const track = new Track(1, { + confidencePairs: [], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + const validTrack = new Track(2, { + confidencePairs: [['leaf', 0.8]], + features: [{ frame: 0, bounds: [1, 1, 2, 2], keyframe: true }], + }); + const getType = vi.spyOn(track, 'getType'); + const validGetType = vi.spyOn(validTrack, 'getType'); + const trackStore = { + intervalTree: { search: vi.fn(() => ['1', '2']) }, + getPossible: vi.fn((id: number) => (id === 1 ? track : validTrack)), + }; + const annotator = { + frame: ref(0), + flick: ref(0), + hasFrame: ref(true), + imageRevision: ref(0), + geoViewerRef: ref({}), + transition: vi.fn(), + }; + provided.values = { + aggregateController: ref({ + getController: vi.fn(() => annotator), + resizeTrigger: ref(0), + }), + handler: {}, + selectedTrackId: ref(null), + trackFilters: { + enabledAnnotations: ref([{ + annotation: track, + context: { confidencePairIndex: -1 }, + }, { + annotation: validTrack, + context: { confidencePairIndex: 0 }, + }]), + hierarchyActive: ref(true), + displayPairIndex: (candidate: Track) => (candidate.confidencePairs.length ? 0 : -1), + }, + trackStyleManager: { + stateStyles: {}, + typeStyling: ref({ color: vi.fn(() => '#000000') }), + }, + editingMode: ref(false), + visibleModes: ref(['rectangle']), + selectedKey: ref('bounds'), + multiSelectList: ref([]), + annotatorPreferences: ref({ + lockedCamera: { enabled: false }, + suppressionDisplay: {}, + trackTails: { before: 0, after: 0 }, + }), + groupStyleManager: { + stateStyles: {}, + typeStyling: ref({ color: vi.fn(() => '#000000') }), + }, + cameraStore: { + camMap: ref(new Map([['singleCam', { trackStore, groupStore: {} }]])), + lookupGroups: vi.fn(() => []), + defaultGroup: ['unknown', 1], + }, + selectedCamera: ref('singleCam'), + attributes: ref([]), + comparisonSets: ref([]), + segmentationPoints: ref({ points: [], labels: [], frameNum: 0 }), + pendingSaveCount: ref(0), + }; + + expect(() => mountLayerManager()).not.toThrow(); + expect(layerMocks.rectangleChangeData).toHaveBeenCalled(); + layerMocks.rectangleChangeData.mock.calls.forEach(([frameData]) => { + expect(frameData).toHaveLength(1); + expect(frameData[0]).toMatchObject({ + track: validTrack, + styleType: ['leaf', 0.8], + }); + }); + expect(getType).not.toHaveBeenCalled(); + expect(validGetType).toHaveBeenCalledWith(0); + }); +}); + +const features: Feature[] = [{ + frame: 0, bounds: [0, 0, 10, 10], keyframe: true, +}]; + +function makeMultiCamFixture( + left: [string, number][], + right: [string, number][], + hierarchy: Record, +) { + const cameraStore = new CameraStore({ markChangesPending: () => undefined }); + cameraStore.removeCamera('singleCam'); + cameraStore.addCamera('left'); + cameraStore.addCamera('right'); + [['left', left], ['right', right]].forEach(([camera, pairs]) => { + const store = cameraStore.camMap.value.get(camera as string)?.trackStore; + store?.insert(new Track(1, { + confidencePairs: pairs as [string, number][], + features, + })); + store?.setEnableSorting(); + }); + const groupFilterControls = new GroupFilterControls({ + sorted: cameraStore.sortedGroups, + remove: () => undefined, + markChangesPending: () => undefined, + setType: () => undefined, + removeTypes: () => [], + }); + const trackFilters = new TrackFilterControls({ + sorted: cameraStore.sortedTracks, + remove: () => undefined, + markChangesPending: () => undefined, + lookupGroups: cameraStore.lookupGroups.bind(cameraStore), + getTrack: (id: AnnotationId, camera = 'left') => cameraStore.getTrack(id, camera), + getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id), + groupFilterControls, + setType: () => undefined, + removeTypes: () => [], + }); + trackFilters.setTypeHierarchy(hierarchy); + return { cameraStore, trackFilters }; +} + +function renderCamera( + cameraStore: CameraStore, + trackFilters: TrackFilterControls, + camera: string, +) { + const annotator = { + frame: ref(0), + flick: ref(0), + hasFrame: ref(true), + imageRevision: ref(0), + geoViewerRef: ref({}), + transition: vi.fn(), + }; + provided.values = { + aggregateController: ref({ + getController: vi.fn(() => annotator), + resizeTrigger: ref(0), + }), + handler: {}, + selectedTrackId: ref(null), + trackFilters, + trackStyleManager: { + stateStyles: {}, + typeStyling: ref({ color: vi.fn(() => '#000000') }), + }, + editingMode: ref(false), + visibleModes: ref(['rectangle']), + selectedKey: ref('bounds'), + multiSelectList: ref([]), + annotatorPreferences: ref({ + lockedCamera: { enabled: false }, + suppressionDisplay: {}, + trackTails: { before: 0, after: 0 }, + }), + groupStyleManager: { + stateStyles: {}, + typeStyling: ref({ color: vi.fn(() => '#000000') }), + }, + cameraStore, + selectedCamera: ref('left'), + attributes: ref([]), + comparisonSets: ref([]), + segmentationPoints: ref({ points: [], labels: [], frameNum: 0 }), + pendingSaveCount: ref(0), + }; + layerMocks.rectangleChangeData.mockClear(); + mountLayerManager({ camera }); + const { calls } = layerMocks.rectangleChangeData.mock; + return calls[calls.length - 1][0] as { styleType: [string, number] }[]; +} + +describe('LayerManager multicamera hierarchy selection', () => { + it('renders each camera deepest qualifying pair instead of the merged index', () => { + const { cameraStore, trackFilters } = makeMultiCamFixture( + [['root', 0.9], ['leaf', 0.8]], + [['root', 0.9]], + { leaf: 'root' }, + ); + expect(renderCamera(cameraStore, trackFilters, 'left')[0].styleType).toEqual(['leaf', 0.8]); + expect(renderCamera(cameraStore, trackFilters, 'right')[0].styleType).toEqual(['root', 0.9]); + }); + + it('resolves a camera whose vector orders the same types differently', () => { + const { cameraStore, trackFilters } = makeMultiCamFixture( + [['fish', 0.9], ['shark', 0.4]], + [['shark', 0.8], ['fish', 0.3]], + { shark: 'fish' }, + ); + expect(renderCamera(cameraStore, trackFilters, 'right')[0].styleType).toEqual(['shark', 0.8]); + }); + + it('hides only the camera whose own vector passes no filter', () => { + const { cameraStore, trackFilters } = makeMultiCamFixture( + [['root', 0.9], ['leaf', 0.8]], + [['root', 0.1]], + { leaf: 'root' }, + ); + trackFilters.setConfidenceFilters({ default: 0.5 }); + expect(renderCamera(cameraStore, trackFilters, 'left')[0].styleType).toEqual(['leaf', 0.8]); + expect(renderCamera(cameraStore, trackFilters, 'right')).toHaveLength(0); + }); +}); diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 3b03d3563..b464c19f5 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -105,7 +105,8 @@ export default defineComponent({ if (!trackStore || !groupStore) { throw Error(`TrackStore: ${trackStore} or GroupStore: ${groupStore} are undefined for camera ${props.camera}`); } - const enabledTracksRef = useTrackFilters().enabledAnnotations; + const trackFilters = useTrackFilters(); + const enabledTracksRef = trackFilters.enabledAnnotations; const selectedTrackIdRef = useSelectedTrackId(); const multiSeletListRef = useMultiSelectList(); const editingModeRef = useEditingMode(); @@ -386,11 +387,18 @@ export default defineComponent({ (trackWithContext) => trackWithContext.annotation.id === trackId, ); if (enabledIndex !== -1) { + // The context index addresses the merged cross-camera vector; a + // camera-local track resolves its own hierarchy pair. + let { confidencePairIndex } = enabledTracks[enabledIndex].context; + if (trackFilters.hierarchyActive.value) { + confidencePairIndex = trackFilters.displayPairIndex(track, 0); + if (confidencePairIndex < 0) { + return; + } + } const [features] = track.getFeature(frame); const groups = cameraStore.lookupGroups(track.id); - const trackStyleType = track.getType( - enabledTracks[enabledIndex].context.confidencePairIndex, - ); + const trackStyleType = track.getType(confidencePairIndex); const groupStyleType = groups?.[0]?.getType() ?? cameraStore.defaultGroup; // A detection flagged with the suppression attribute (it is NOT // under a region — those are hidden above) stays visible but is @@ -408,6 +416,7 @@ export default defineComponent({ groups, features, styleType, + trackStyleType, suppressed, set: track.set, }; @@ -550,6 +559,7 @@ export default defineComponent({ groups: cameraStore.lookupGroups(editTrack.id), features: (features && features.interpolate) ? features : null, styleType: cameraStore.defaultGroup, // Won't be used + trackStyleType: cameraStore.defaultGroup, // Won't be used }; editingTracks.push(trackFrame); } diff --git a/client/src/components/Tracks/TrackItem.vue b/client/src/components/Tracks/TrackItem.vue index 0ae7c714b..89b4c1258 100644 --- a/client/src/components/Tracks/TrackItem.vue +++ b/client/src/components/Tracks/TrackItem.vue @@ -23,6 +23,10 @@ export default defineComponent({ type: String, required: true, }, + displayPairIndex: { + type: Number, + required: true, + }, track: { type: Object as PropType, required: true, diff --git a/client/src/components/Tracks/TrackList.spec.ts b/client/src/components/Tracks/TrackList.spec.ts new file mode 100644 index 000000000..663826760 --- /dev/null +++ b/client/src/components/Tracks/TrackList.spec.ts @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +/// +import { + defineComponent, h, nextTick, ref, Ref, +} from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Track from '../../track'; +import TrackList from './TrackList.vue'; + +interface MockCameraStore { + camMap: Ref>; + getTracksMerged: (id: number) => Track | undefined; + getAnyPossibleTrack: (id: number) => Track | undefined; +} + +interface MockTrackFilters { + allTypes: Ref; + checkedIDs: Ref; + filteredAnnotations: Ref<{ + annotation: ReturnType; + context: { confidencePairIndex: number }; + }[]>; + hierarchyActive: Ref; +} + +const state = vi.hoisted(() => ({ + cameraStore: null as unknown as MockCameraStore, + trackFilters: null as unknown as MockTrackFilters, +})); + +vi.mock('dive-common/vue-utilities/prompt-service', () => ({ + usePrompt: () => ({ prompt: vi.fn() }), +})); + +vi.mock('../../use/useVirtualScrollTo', () => ({ + default: () => ({ virtualList: ref(null), scrollPreventDefault: vi.fn() }), +})); + +vi.mock('../../provides', () => ({ + useEditingMode: () => ref(false), + useHandler: () => ({ + trackSplit: vi.fn(), + removeTrack: vi.fn(), + trackAdd: vi.fn(), + trackSelect: vi.fn(), + trackSelectNext: vi.fn(), + }), + useSelectedTrackId: () => ref(null), + useTrackFilters: () => state.trackFilters, + useTime: () => ({ frame: ref(0), isPlaying: ref(false) }), + useReadOnlyMode: () => ref(false), + useTrackStyleManager: () => ({ + typeStyling: ref({ color: (type: string) => `color:${type}` }), + }), + useMultiSelectList: () => ref([]), + useCameraStore: () => state.cameraStore, + useSelectedCamera: () => ref('singleCam'), + usePendingSaveCount: () => ref(0), +})); + +function sortedTrack(track: Track) { + return { + id: track.id, + begin: track.begin, + end: track.end, + confidencePairs: track.confidencePairs, + getType: (index = 0) => track.confidencePairs[index][0], + }; +} + +function mountList( + tracks: Track[], + contextIndexes: number[], + hierarchyActive = true, + filtered = tracks.map((track, index) => ({ + annotation: sortedTrack(track), + context: { confidencePairIndex: contextIndexes[index] }, + })), +) { + const byId = new Map(tracks.map((track) => [track.id, track])); + state.trackFilters = { + allTypes: ref(['root', 'child', 'leaf']), + checkedIDs: ref(tracks.map(({ id }) => id)), + filteredAnnotations: ref(filtered), + hierarchyActive: ref(hierarchyActive), + }; + state.cameraStore = { + camMap: ref(new Map([['singleCam', { trackStore: undefined }]])), + getTracksMerged: (id: number) => byId.get(id), + getAnyPossibleTrack: (id: number) => byId.get(id), + }; + // `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` + // SFC is not, so the list renders from a host that captures the real instance. It stays + // unstubbed to keep shallow semantics for its own children. + let child: InstanceType | undefined; + const Host = defineComponent({ + setup: () => () => h(TrackList, { + props: { + compact: true, + newTrackMode: 'Track', + newTrackType: 'unknown', + hotkeysDisabled: false, + }, + ref: (instance) => { + if (instance && !(instance instanceof Element)) { + child = instance as InstanceType; + } + }, + }), + }); + const wrapper = shallowMount(Host, { stubs: { TrackList: false } }); + if (!child) { + throw new Error('TrackList did not mount'); + } + return { wrapper, vm: child }; +} + +describe('TrackList hierarchy display', () => { + it.each([ + ['monotone leaf', [['root', 0.9], ['child', 0.8], ['leaf', 0.7]], 2, 'leaf'], + ['non-monotone leaf', [['root', 0.2], ['child', 0.9], ['leaf', 0.6]], 2, 'leaf'], + ['unchecked-leaf roll-up', [['root', 0.9], ['child', 0.8], ['leaf', 0.7]], 1, 'child'], + ] as [string, [string, number][], number, string][])( + 'passes the context-selected type, confidence index, and color for %s', + (_name, pairs, pairIndex, expectedType) => { + const track = new Track(1, { + confidencePairs: pairs, + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + const { wrapper } = mountList([track], [pairIndex]); + const listView = wrapper.findComponent({ name: 'BottomBarTrackListView' }); + const items = listView.props('virtualListItems') as unknown[]; + const getItemProps = listView.props('getItemProps') as (item: unknown) => Record; + expect(getItemProps(items[0])).toMatchObject({ + trackType: expectedType, + displayPairIndex: pairIndex, + color: `color:${expectedType}`, + }); + }, + ); + + it('sorts hierarchy confidence by the context pair and flat confidence by pair zero', async () => { + const first = new Track(1, { + confidencePairs: [['root', 0.9], ['leaf', 0.4]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + const second = new Track(2, { + confidencePairs: [['root', 0.5], ['leaf', 0.8]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + const { wrapper, vm } = mountList([first, second], [1, 1]); + vm.handleSort('confidence'); + expect(vm.filteredTracks.map(({ annotation }) => annotation.id)).toEqual([2, 1]); + + state.trackFilters.hierarchyActive.value = false; + await nextTick(); + expect(vm.filteredTracks.map(({ annotation }) => annotation.id)).toEqual([1, 2]); + const listView = wrapper.findComponent({ name: 'BottomBarTrackListView' }); + const items = listView.props('virtualListItems') as unknown[]; + const getItemProps = listView.props('getItemProps') as (item: unknown) => Record; + expect(getItemProps(items[0])).toMatchObject({ displayPairIndex: 0 }); + }); + + it('renders no row or fake type for an excluded empty hierarchy vector', () => { + const track = new Track(1, { + confidencePairs: [], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + const annotation = sortedTrack(track); + const getType = vi.spyOn(annotation, 'getType'); + + const { wrapper } = mountList([track], [-1], true, [{ + annotation, + context: { confidencePairIndex: -1 }, + }]); + const listView = wrapper.findComponent({ name: 'BottomBarTrackListView' }); + expect(listView.props('virtualListItems')).toEqual([]); + expect(getType).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index 8a83cded3..40d8a4a00 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -104,6 +104,14 @@ export default defineComponent({ const sortKey = ref('id'); const sortDirection = ref('asc'); + const displayConfidence = ( + track: ReturnType, + contextIndex: number, + ) => { + const pairIndex = trackFilters.hierarchyActive.value ? contextIndex : 0; + return track.confidencePairs[pairIndex]?.[1] ?? 0; + }; + const filterDetectionsByFrame = ref(clientSettings.trackSettings.trackListSettings.filterDetectionsByFrame); watch( () => clientSettings.trackSettings.trackListSettings.filterDetectionsByFrame, @@ -114,6 +122,11 @@ export default defineComponent({ const finalFilteredTracks = computed(() => { let tracks = filteredTracksRef.value; + if (trackFilters.hierarchyActive.value) { + tracks = tracks.filter(({ annotation, context }) => ( + annotation.confidencePairs[context.confidencePairIndex] !== undefined + )); + } if (filterDetectionsByFrame.value && !isPlaying.value) { // Depend on the edit counter so moving a suppression region re-runs the // filter (geometry mutations are not reactive track-set changes). @@ -228,8 +241,8 @@ export default defineComponent({ case 'endTime': return (trackA.end - trackB.end) * direction; case 'confidence': { - const confA = trackA.confidencePairs?.[0]?.[1] ?? 0; - const confB = trackB.confidencePairs?.[0]?.[1] ?? 0; + const confA = displayConfidence(trackA, a.context.confidencePairIndex); + const confB = displayConfidence(trackB, b.context.confidencePairIndex); return (confA - confB) * direction; } case 'type': { @@ -238,8 +251,8 @@ export default defineComponent({ const typeCompare = typeA.localeCompare(typeB); if (typeCompare !== 0) return typeCompare * direction; // Secondary sort by confidence within same type - const confA = trackA.confidencePairs?.[0]?.[1] ?? 0; - const confB = trackB.confidencePairs?.[0]?.[1] ?? 0; + const confA = displayConfidence(trackA, a.context.confidencePairIndex); + const confB = displayConfidence(trackB, b.context.confidencePairIndex); return (confA - confB) * direction; } case 'notes': { @@ -320,6 +333,9 @@ export default defineComponent({ editing: selected && item.editingTrack, color: typeStylingRef.value.color(trackType), types: item.allTypes, + displayPairIndex: trackFilters.hierarchyActive.value + ? item.filteredTrack.context.confidencePairIndex + : 0, }; } diff --git a/client/src/components/Tracks/bottombar/BottomBarTrackItemView.spec.ts b/client/src/components/Tracks/bottombar/BottomBarTrackItemView.spec.ts new file mode 100644 index 000000000..c0c24a28a --- /dev/null +++ b/client/src/components/Tracks/bottombar/BottomBarTrackItemView.spec.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +/// +import { defineComponent, h, ref } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Track from '../../../track'; +import BottomBarTrackItemView from './BottomBarTrackItemView.vue'; + +const providerState = vi.hoisted(() => ({ setTrackType: vi.fn() })); + +vi.mock('../../../provides', () => ({ + useHandler: () => ({ trackSeek: vi.fn(), removeTrack: vi.fn(), trackEdit: vi.fn() }), + useReadOnlyMode: () => ref(false), + useTrackFilters: () => ({ allTypes: ref(['root', 'leaf']) }), + useCameraStore: () => ({ setTrackType: providerState.setTrackType }), +})); + +function mountItem(displayPairIndex: number) { + const track = new Track(1, { + confidencePairs: [['root', 0.9], ['leaf', 0.7]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + Object.preventExtensions(track); + // `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` + // SFC is not, so the row renders from a host and stays unstubbed to keep shallow semantics + // for its own children. + let child: InstanceType | undefined; + const Host = defineComponent({ + setup: () => () => h(BottomBarTrackItemView, { + ref: (instance) => { + if (instance && !(instance instanceof Element)) { + child = instance as InstanceType; + } + }, + props: { + track, + trackType: displayPairIndex === 1 ? 'leaf' : 'root', + displayPairIndex, + itemStyle: {}, + color: displayPairIndex === 1 ? '#leaf' : '#root', + editing: false, + inputValue: true, + toggleKeyframe: vi.fn(), + toggleInterpolation: vi.fn(), + toggleAllInterpolation: vi.fn(), + }, + }), + }); + const wrapper = shallowMount(Host, { stubs: { BottomBarTrackItemView: false } }); + if (!child) { + throw new Error('BottomBarTrackItemView did not mount'); + } + return { wrapper, vm: child, props: child.$props }; +} + +describe('BottomBarTrackItemView hierarchy display', () => { + it('renders and seeds editing from the selected hierarchy pair', () => { + const { wrapper, vm } = mountItem(1); + expect(wrapper.find('.track-type-compact').text()).toBe('leaf'); + expect(wrapper.find('.track-confidence-compact').text()).toBe('0.70'); + vm.startEditConfidence(new MouseEvent('click')); + expect(vm.editConfidenceValue).toBe('0.70'); + }); + + it('retains pair-zero type and confidence in flat mode', () => { + const { wrapper } = mountItem(0); + expect(wrapper.find('.track-type-compact').text()).toBe('root'); + expect(wrapper.find('.track-confidence-compact').text()).toBe('0.90'); + }); +}); diff --git a/client/src/components/Tracks/bottombar/BottomBarTrackItemView.vue b/client/src/components/Tracks/bottombar/BottomBarTrackItemView.vue index ef088ef34..382de7120 100644 --- a/client/src/components/Tracks/bottombar/BottomBarTrackItemView.vue +++ b/client/src/components/Tracks/bottombar/BottomBarTrackItemView.vue @@ -18,6 +18,7 @@ export default defineComponent({ props: { track: { type: Object as PropType, required: true }, trackType: { type: String, required: true }, + displayPairIndex: { type: Number, required: true }, itemStyle: { type: Object, required: true }, color: { type: String, required: true }, lockTypes: { type: Boolean, default: false }, @@ -61,7 +62,7 @@ export default defineComponent({ if (props.track.revision.value !== undefined && props.track.confidencePairs && props.track.confidencePairs.length > 0) { - return props.track.confidencePairs[0][1]; + return props.track.confidencePairs[props.displayPairIndex]?.[1] ?? null; } return null; }); diff --git a/client/src/components/TypeEditor.spec.ts b/client/src/components/TypeEditor.spec.ts new file mode 100644 index 000000000..561610c5d --- /dev/null +++ b/client/src/components/TypeEditor.spec.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +/// +import { defineComponent, h, ref } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { TypeHierarchyError } from 'dive-common/typeHierarchy'; +import TrackFilterControls from '../TrackFilterControls'; +import TypeEditor from './TypeEditor.vue'; + +const promptMock = vi.hoisted(() => vi.fn()); + +vi.mock('dive-common/vue-utilities/prompt-service', () => ({ + usePrompt: () => ({ prompt: promptMock }), +})); + +vi.mock('../provides', () => ({ + useReadOnlyMode: () => ref(false), +})); + +function makeFilters() { + const filters = Object.create(TrackFilterControls.prototype) as TrackFilterControls; + filters.usedTypes = ref([]); + filters.typeInUseOnAnyCamera = vi.fn(() => false); + filters.updateTypeName = vi.fn(); + filters.importTypes = vi.fn(); + filters.deleteType = vi.fn(() => true); + return filters; +} + +function makeStyleManager() { + return Object.freeze({ + typeStyling: ref({ + color: () => '#123456', + strokeWidth: () => 3, + fill: () => false, + opacity: () => 0.8, + labelSettings: () => ({ showLabel: true, showConfidence: true }), + }), + updateTypeStyle: vi.fn(), + }); +} + +/** + * `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` + * SFC is not, so the editor is rendered from a host that captures the real instance and its + * emitted events. It is left unstubbed to keep shallow semantics for its own children. + */ +function mountEditor(filters = makeFilters(), styleManager = makeStyleManager()) { + const closeEvents: unknown[] = []; + let child: InstanceType | undefined; + const Host = defineComponent({ + setup: () => () => h(TypeEditor, { + props: { + selectedType: 'leaf', + filterControls: filters, + styleManager, + }, + on: { close: () => closeEvents.push([]) }, + ref: (instance) => { + if (instance && !(instance instanceof Element)) { + child = instance as InstanceType; + } + }, + }), + }); + const wrapper = shallowMount(Host, { stubs: { TypeEditor: false } }); + if (!child) { + throw new Error('TypeEditor did not mount'); + } + return { + filters, styleManager, wrapper, vm: child, closeEvents, + }; +} + +describe('TypeEditor hierarchy safety', () => { + beforeEach(() => promptMock.mockReset()); + + it('allows clearing settings for an unused hierarchy parent', async () => { + const filters = makeFilters(); + promptMock.mockResolvedValue(true); + const { vm } = mountEditor(filters); + await vm.clickDeleteType('leaf'); + expect(promptMock).toHaveBeenCalled(); + expect(filters.deleteType).toHaveBeenCalledWith('leaf'); + }); + + it('disables deletion for a type used only by a camera the merged view hides', () => { + const filters = makeFilters(); + vi.mocked(filters.typeInUseOnAnyCamera).mockReturnValue(true); + const { vm, wrapper } = mountEditor(filters); + expect(vm.deleteBlocked).toBe(true); + expect(wrapper.text()).toContain('Only types without any annotations can be deleted.'); + }); + + it('leaves an unused leaf unchanged when deletion is canceled', async () => { + promptMock.mockResolvedValue(false); + const { filters, vm, closeEvents } = mountEditor(); + await vm.clickDeleteType('leaf'); + expect(promptMock).toHaveBeenCalledTimes(1); + expect(filters.deleteType).not.toHaveBeenCalled(); + expect(closeEvents).toHaveLength(0); + }); + + it('keeps the editor open for a rejected rename and allows a corrected retry', () => { + const { + filters, styleManager, vm, closeEvents, + } = mountEditor(); + vi.mocked(filters.updateTypeName).mockImplementationOnce(() => { + throw new TypeHierarchyError('self edge "root -> root"', 'conflict'); + }); + vm.data.editingType = 'root'; + vm.acceptChanges(); + expect(vm.data.renameError).toBe( + 'Type hierarchy is invalid: self edge "root -> root". No types were changed.', + ); + expect(styleManager.updateTypeStyle).not.toHaveBeenCalled(); + expect(closeEvents).toHaveLength(0); + + vm.data.editingType = 'fin'; + vm.acceptChanges(); + expect(vm.data.renameError).toBe(''); + expect(filters.updateTypeName).toHaveBeenLastCalledWith({ + currentType: 'leaf', newType: 'fin', + }); + expect(closeEvents).toHaveLength(1); + }); + + it('promotes a hierarchy-only heading only after a style value changes', () => { + const { filters, vm } = mountEditor(); + vm.acceptChanges(); + expect(filters.importTypes).not.toHaveBeenCalled(); + + const changed = mountEditor(); + changed.vm.data.editingColor = '#abcdef'; + changed.vm.acceptChanges(); + expect(changed.filters.importTypes).toHaveBeenCalledWith(['leaf'], false); + }); +}); diff --git a/client/src/components/TypeEditor.vue b/client/src/components/TypeEditor.vue index 1f6bfba83..89885ec40 100644 --- a/client/src/components/TypeEditor.vue +++ b/client/src/components/TypeEditor.vue @@ -1,16 +1,17 @@