Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ type DatasetInfoFields = Record<string, unknown>;
* The parts of dataset config a user should be able to modify.
*/
interface DatasetConfigMutable {
typeHierarchy?: Record<string, string> | null;
customTypeStyling?: Record<string, CustomStyle>;
customGroupStyling?: Record<string, CustomStyle>;
confidenceFilters?: Record<string, number>;
Expand All @@ -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).
Expand Down
1 change: 1 addition & 0 deletions client/dive-common/components/BottomPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export default defineComponent({
<template #settings>
<TypeSettingsPanel
:all-types="trackFilters.allTypes.value"
:hierarchy-active="trackFilters.hierarchyActive.value"
@import-types="trackFilters.importTypes($event)"
/>
</template>
Expand Down
3 changes: 3 additions & 0 deletions client/dive-common/components/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export default defineComponent({
readOnlyMode,
styleManager,
disableAnnotationFilters: trackFilterControls.disableAnnotationFilters,
hierarchyActive: trackFilterControls.hierarchyActive,
confidenceFilters: trackFilterControls.confidenceFilters,
visible,
horizontalTabIcon,
Expand Down Expand Up @@ -194,6 +195,7 @@ export default defineComponent({
<template #settings>
<TypeSettingsPanel
:all-types="allTypesRef"
:hierarchy-active="hierarchyActive"
@import-types="$emit('import-types', $event)"
/>
</template>
Expand Down Expand Up @@ -396,6 +398,7 @@ export default defineComponent({
<template #settings>
<TypeSettingsPanel
:all-types="allTypesRef"
:hierarchy-active="hierarchyActive"
@import-types="$emit('import-types', $event)"
/>
</template>
Expand Down
109 changes: 109 additions & 0 deletions client/dive-common/components/TrackDetailsPanel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
/// <reference types="vitest" />
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<typeof TrackDetailsPanel> | undefined;
const Host = defineComponent({
setup: () => () => h(TrackDetailsPanel, {
props: { hotkeysDisabled: false },
ref: (instance) => {
if (instance && !(instance instanceof Element)) {
child = instance as InstanceType<typeof TrackDetailsPanel>;
}
},
}),
});
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);
});
});
26 changes: 22 additions & 4 deletions client/dive-common/components/TrackDetailsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export default defineComponent({
const editingError: Ref<string | null> = 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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -302,6 +316,8 @@ export default defineComponent({
updateSelectedTracksType,
setTrackType,
displayConfidencePairs,
displayRows,
trackFilters,
};
},
});
Expand Down Expand Up @@ -423,23 +439,25 @@ export default defineComponent({
class="track-details"
>
<v-card
v-for="track in selectedTrackList"
v-for="{ track, pair, pairIndex } in displayRows"
:key="track.trackId"
class="mx-2 mb-2"
outlined
flat
>
<div class="d-flex align-center">
<TrackItem
v-if="pair"
:solo="true"
:merging="multiSelectInProgress"
:track="track"
:track-type="track.confidencePairs[0][0]"
:track-type="pair[0]"
:display-pair-index="pairIndex"
:selected="selectedTrackIdRef === track.id"
:secondary-selected="true"
:editing="!!editingModeRef"
:input-value="true"
:color="typeStylingRef.color(track.confidencePairs[0][0])"
:color="typeStylingRef.color(pair[0])"
:lock-types="lockTypes"
:disabled="disabled"
class="grow"
Expand Down
69 changes: 69 additions & 0 deletions client/dive-common/components/TypeSettingsPanel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
/// <reference types="vitest" />
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<PanelProps>) => {
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(),
);
});
});
11 changes: 11 additions & 0 deletions client/dive-common/components/TypeSettingsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export default defineComponent({
type: Array as PropType<Array<string>>,
required: true,
},
hierarchyActive: {
type: Boolean,
required: true,
},
},
setup(props, { emit }) {
const itemHeight = 45; // in pixels
Expand Down Expand Up @@ -204,7 +208,14 @@ export default defineComponent({
class="my-0 ml-1 pt-0"
dense
hide-details
:disabled="hierarchyActive"
/>
<div
v-if="hierarchyActive"
class="ml-1 text-caption"
>
Not applicable to hierarchical types; DIVE selects the deepest qualifying type.
</div>
</v-col>
<v-col
cols="2"
Expand Down
25 changes: 23 additions & 2 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ export default defineComponent({
markChangesPending: (markChangesPending as MarkChangesPendingFilter),
lookupGroups: cameraStore.lookupGroups,
getTrack: (track: AnnotationId, camera = 'singleCam') => (cameraStore.getTrack(track, camera)),
getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track),
groupFilterControls: groupFilters,
setType: setTrackType,
removeTypes,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 */
Expand Down
Loading
Loading