diff --git a/client/dive-common/components/ActionEditors/ActionEditor.vue b/client/dive-common/components/ActionEditors/ActionEditor.vue index 00197519..a374840f 100644 --- a/client/dive-common/components/ActionEditors/ActionEditor.vue +++ b/client/dive-common/components/ActionEditors/ActionEditor.vue @@ -75,7 +75,7 @@ export default defineComponent({ type: 'CreateTrackAction', geometryType: 'rectangle', editableType: true, - selectTrackAfter: false, + selectTrackAfter: 'previousTrack', }, }; } diff --git a/client/dive-common/components/ActionEditors/ActionEditorSettings.vue b/client/dive-common/components/ActionEditors/ActionEditorSettings.vue index 830adfc1..0e0a88d4 100644 --- a/client/dive-common/components/ActionEditors/ActionEditorSettings.vue +++ b/client/dive-common/components/ActionEditors/ActionEditorSettings.vue @@ -103,7 +103,7 @@ export default defineComponent({ type: 'CreateTrackAction', geometryType: 'rectangle', editableType: true, - selectTrackAfter: true, + selectTrackAfter: 'previousTrack', }, }; } diff --git a/client/dive-common/components/ActionEditors/ActionShortcuts.vue b/client/dive-common/components/ActionEditors/ActionShortcuts.vue index f08b620b..fe0b1261 100644 --- a/client/dive-common/components/ActionEditors/ActionShortcuts.vue +++ b/client/dive-common/components/ActionEditors/ActionShortcuts.vue @@ -6,7 +6,7 @@ import { } from 'vue'; import draggable from 'vuedraggable'; import { - DIVEAction, DIVEActionShortcut, TrackSelectAction, + DIVEAction, DIVEActionShortcut, TrackSelectAction, getCreateTrackAfterSelectionLabel, } from 'dive-common/use/useActions'; import { ButtonShortcut } from 'vue-media-annotator/use/AttributeTypes'; import { @@ -188,6 +188,13 @@ export default defineComponent({ updateActionList(); }; + const removeAction = (index: number) => { + if (editingShortcut.value) { + editingShortcut.value.actions.splice(index, 1); + updateActionList(); + } + }; + const saveShortcutDisabled = computed(() => { if (editingShortcut.value) { const { description } = editingShortcut.value; @@ -217,11 +224,13 @@ export default defineComponent({ updateActionList, addEditActionindex, saveAction, + removeAction, saveShortcutDisabled, getShortcutDisplay, hasKeyboardShortcut, isIconOnlyButton, onShortcutOrderEnd, + getCreateTrackAfterSelectionLabel, }; }, @@ -474,11 +483,8 @@ export default defineComponent({
- Select Track After: - mdi-check - - mdi-close - + After creation: + {{ getCreateTrackAfterSelectionLabel(item.action.selectTrackAfter) }}
diff --git a/client/dive-common/components/ActionEditors/CreateTrackActionEditor.vue b/client/dive-common/components/ActionEditors/CreateTrackActionEditor.vue index f6e8d577..9480445a 100644 --- a/client/dive-common/components/ActionEditors/CreateTrackActionEditor.vue +++ b/client/dive-common/components/ActionEditors/CreateTrackActionEditor.vue @@ -4,7 +4,11 @@ import { watch, } from 'vue'; import { EditAnnotationTypes } from 'vue-media-annotator/layers/'; -import { CreateTrackAction } from 'dive-common/use/useActions'; +import { + CreateTrackAction, + CREATE_TRACK_AFTER_SELECTION_OPTIONS, + normalizeCreateTrackAfterSelection, +} from 'dive-common/use/useActions'; export default defineComponent({ name: 'CreateTrackActionEditor', @@ -17,12 +21,16 @@ export default defineComponent({ emits: ['update:action', 'cancel'], setup(props, { emit }) { const geometryTypes: EditAnnotationTypes[] = ['Point', 'rectangle', 'Polygon', 'LineString', 'Time']; - const localAction = ref({ ...props.action }); - const editableTypeListEnabled = ref(false); - const editableTypeList: Ref = ref(''); + const localAction = ref({ + ...props.action, + selectTrackAfter: normalizeCreateTrackAfterSelection(props.action.selectTrackAfter), + }); + const editableTypeListEnabled = ref(!!props.action.editableTypeList?.length); + const editableTypeList: Ref = ref((props.action.editableTypeList || []).join(', ')); watch(editableTypeList, () => { localAction.value.editableTypeList = editableTypeList.value.split(',').map((type) => type.trim()); }); + const afterSelectionOptions = CREATE_TRACK_AFTER_SELECTION_OPTIONS; const cancel = () => { emit('cancel'); }; @@ -34,6 +42,7 @@ export default defineComponent({ geometryTypes, editableTypeList, editableTypeListEnabled, + afterSelectionOptions, cancel, save, }; @@ -121,12 +130,22 @@ export default defineComponent({ dense /> - - + After track creation + + + dense + hide-details + class="mt-0" + > + + diff --git a/client/dive-common/components/CustomUI/CustomUIBase.vue b/client/dive-common/components/CustomUI/CustomUIBase.vue index 6eba3123..57503b38 100644 --- a/client/dive-common/components/CustomUI/CustomUIBase.vue +++ b/client/dive-common/components/CustomUI/CustomUIBase.vue @@ -7,6 +7,8 @@ import { import StackedVirtualSidebarContainer from 'dive-common/components/StackedVirtualSidebarContainer.vue'; import CustomUIAttributeValueDisplay from 'dive-common/components/CustomUI/CustomUIAttributeValueDisplay.vue'; +import CustomUITrackList from 'dive-common/components/CustomUI/CustomUITrackList.vue'; +import CustomUIEditingStatus from 'dive-common/components/CustomUI/CustomUIEditingStatus.vue'; import { useAttributes, useCameraStore, useConfiguration, useSelectedTrackId, useTime, useHandler, useTrackStyleManager, @@ -77,6 +79,8 @@ export default defineComponent({ StackedVirtualSidebarContainer, AttributeSubsection, CustomUIAttributeValueDisplay, + CustomUITrackList, + CustomUIEditingStatus, }, props: { @@ -103,6 +107,19 @@ export default defineComponent({ const title = computed(() => configMan.configuration.value?.customUI?.title || 'Custom Actions'); const information = computed(() => configMan.configuration.value?.customUI?.information || []); const updatedWidth = computed(() => configMan.configuration.value?.customUI?.width || props.width); + const trackListSettings = computed(() => configMan.configuration.value?.customUI?.trackList); + const trackListEnabled = computed(() => ( + trackListSettings.value !== undefined && trackListSettings.value.enabled !== false + )); + const showEditingStatus = computed(() => ( + trackListEnabled.value && trackListSettings.value?.showEditingStatus !== false + )); + const editingStatusTitle = computed(() => ( + trackListSettings.value?.editingStatusTitle || 'Current Mode' + )); + const trackListAboveActions = computed(() => ( + trackListSettings.value?.position === 'above' + )); context.nudgeWidth(updatedWidth.value); function getAttributeUser({ name, belongs }: { name: string; belongs: 'track' | 'detection' }) { const attribute = attributes.value.find((attr) => attr.name === name && attr.belongs === belongs); @@ -762,6 +779,11 @@ export default defineComponent({ getDisplayValueEntry, shouldShowDisplayValue, LONG_VALUE_EXPAND_THRESHOLD, + trackListSettings, + trackListEnabled, + showEditingStatus, + editingStatusTitle, + trackListAboveActions, }; }, }); @@ -775,6 +797,14 @@ export default defineComponent({ {{ item }}

+ Action Buttons @@ -802,10 +832,21 @@ export default defineComponent({
+ -

+

Attribute Action Buttons are disabled because no track is selected

+

+ Select a track from the list above to use attribute buttons +

Attribute Buttons

diff --git a/client/dive-common/components/CustomUI/CustomUIEditingStatus.vue b/client/dive-common/components/CustomUI/CustomUIEditingStatus.vue new file mode 100644 index 00000000..e39e55bc --- /dev/null +++ b/client/dive-common/components/CustomUI/CustomUIEditingStatus.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/client/dive-common/components/CustomUI/CustomUITrackList.vue b/client/dive-common/components/CustomUI/CustomUITrackList.vue new file mode 100644 index 00000000..61b9e6da --- /dev/null +++ b/client/dive-common/components/CustomUI/CustomUITrackList.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/client/dive-common/components/CustomUI/useCustomUITrackList.ts b/client/dive-common/components/CustomUI/useCustomUITrackList.ts new file mode 100644 index 00000000..5b4aac26 --- /dev/null +++ b/client/dive-common/components/CustomUI/useCustomUITrackList.ts @@ -0,0 +1,103 @@ +import { computed, Ref } from 'vue'; +import { intersection } from 'lodash'; +import { CustomUITrackListSettings } from 'vue-media-annotator/ConfigurationManager'; +import { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; +import Track from 'vue-media-annotator/track'; +import { + useHandler, + useReadOnlyMode, + useSelectedTrackId, + useTrackFilters, + useEditingMode, +} from 'vue-media-annotator/provides'; + +export interface CustomUITrackListEntry { + track: Track; + trackId: AnnotationId; + trackType: string; +} + +function trackMatchesTypeFilter(track: Track, typeFilter: string[]): boolean { + if (!typeFilter.length) { + return false; + } + const types = track.confidencePairs.map((item) => item[0]); + return intersection(types, typeFilter).length > 0; +} + +function resolveSettings(settings: CustomUITrackListSettings | undefined) { + return { + enabled: settings?.enabled !== false, + title: settings?.title || 'Tracks', + defaultExpanded: settings?.defaultExpanded ?? false, + typeFilter: settings?.typeFilter || [], + maxHeight: settings?.maxHeight ?? 240, + actions: { + select: settings?.actions?.select !== false, + edit: settings?.actions?.edit !== false, + delete: settings?.actions?.delete !== false, + }, + display: { + showType: settings?.display?.showType !== false, + showFrameRange: settings?.display?.showFrameRange !== false, + showTrackId: settings?.display?.showTrackId !== false, + }, + showEditingStatus: settings?.showEditingStatus !== false, + editingStatusTitle: settings?.editingStatusTitle || 'Current Mode', + }; +} + +export default function useCustomUITrackList(settingsRef: Ref) { + const trackFilters = useTrackFilters(); + const handler = useHandler(); + const selectedTrackIdRef = useSelectedTrackId(); + const editingModeRef = useEditingMode(); + const readOnlyMode = useReadOnlyMode(); + const resolvedSettings = computed(() => resolveSettings(settingsRef.value)); + + const filteredTracks = computed((): CustomUITrackListEntry[] => { + const { typeFilter } = resolvedSettings.value; + if (!typeFilter.length) { + return []; + } + return trackFilters.filteredAnnotations.value + .filter((item) => trackMatchesTypeFilter(item.annotation, typeFilter)) + .map((item) => { + const confidencePair = item.annotation.getType(item.context.confidencePairIndex); + return { + track: item.annotation, + trackId: item.annotation.id, + trackType: confidencePair[0], + }; + }); + }); + + const isSelected = (trackId: AnnotationId) => selectedTrackIdRef.value === trackId; + + const isEditing = (trackId: AnnotationId) => ( + isSelected(trackId) && !!editingModeRef.value + ); + + const selectTrack = (trackId: AnnotationId) => { + handler.trackSeek(trackId); + }; + + const editTrack = (trackId: AnnotationId) => { + handler.trackEdit(trackId); + }; + + const deleteTrack = (trackId: AnnotationId) => { + handler.removeTrack([trackId]); + }; + + return { + resolvedSettings, + filteredTracks, + readOnlyMode, + isSelected, + isEditing, + selectTrack, + editTrack, + deleteTrack, + }; +} diff --git a/client/dive-common/components/CustomUI/useEditingStatus.ts b/client/dive-common/components/CustomUI/useEditingStatus.ts new file mode 100644 index 00000000..f02a263e --- /dev/null +++ b/client/dive-common/components/CustomUI/useEditingStatus.ts @@ -0,0 +1,78 @@ +import { computed } from 'vue'; +import { + useCameraStore, + useEditingGroupId, + useEditingMode, + useMultiSelectList, + useSelectedCamera, + useSelectedTrackId, + useTime, +} from 'vue-media-annotator/provides'; +import { + computeEditingDetails, + getEditingInstruction, + GROUP_EDIT_INSTRUCTION, + IDLE_INSTRUCTION, + MULTI_SELECT_INSTRUCTION, +} from 'dive-common/use/editingModeInstructions'; + +export default function useEditingStatus() { + const selectedTrackIdRef = useSelectedTrackId(); + const editingModeRef = useEditingMode(); + const cameraStore = useCameraStore(); + const selectedCamera = useSelectedCamera(); + const { frame } = useTime(); + const editingGroupIdRef = useEditingGroupId(); + const multiSelectList = useMultiSelectList(); + + const groupEditActive = computed(() => editingGroupIdRef.value !== null); + const multiSelectActive = computed(() => multiSelectList.value.length > 0); + + const editingDetails = computed(() => computeEditingDetails( + !!editingModeRef.value, + selectedTrackIdRef.value, + editingModeRef.value, + frame.value, + (trackId) => cameraStore.getPossibleTrack(trackId, selectedCamera.value), + )); + + const statusHeader = computed(() => { + if (groupEditActive.value) { + return { text: 'Group Edit Mode', icon: 'mdi-group', color: 'primary' }; + } + if (multiSelectActive.value) { + return { text: 'Multi-select Mode', icon: 'mdi-call-merge', color: 'error' }; + } + if (editingDetails.value !== 'disabled' && editingModeRef.value) { + return { + text: `${editingDetails.value} ${editingModeRef.value}`, + icon: editingDetails.value === 'Creating' ? 'mdi-pencil-plus' : 'mdi-pencil', + color: editingDetails.value === 'Creating' ? 'success' : 'primary', + }; + } + return { text: 'Not editing', icon: 'mdi-pencil-off-outline', color: '' }; + }); + + const instructionText = computed(() => { + if (groupEditActive.value) { + return GROUP_EDIT_INSTRUCTION; + } + if (multiSelectActive.value) { + return MULTI_SELECT_INSTRUCTION; + } + return getEditingInstruction(editingDetails.value, editingModeRef.value); + }); + + const isActive = computed(() => ( + groupEditActive.value + || multiSelectActive.value + || editingDetails.value !== 'disabled' + )); + + return { + statusHeader, + instructionText, + isActive, + idleInstruction: IDLE_INSTRUCTION, + }; +} diff --git a/client/dive-common/components/EditorMenu.vue b/client/dive-common/components/EditorMenu.vue index e45ab595..12ce531b 100644 --- a/client/dive-common/components/EditorMenu.vue +++ b/client/dive-common/components/EditorMenu.vue @@ -10,6 +10,12 @@ import { hexToRgb } from 'vue-media-annotator/utils'; import { useMasks, useConfiguration, useVisualMaskManager } from 'vue-media-annotator/provides'; import { useStore } from 'platform/web-girder/store/types'; import MaskTracking from 'dive-common/components/MaskTracking.vue'; +import { + EDITING_MODE_INSTRUCTIONS, + GROUP_EDIT_INSTRUCTION, + IDLE_INSTRUCTION, + MULTI_SELECT_INSTRUCTION, +} from 'dive-common/use/editingModeInstructions'; interface ButtonData { id: string; @@ -91,22 +97,7 @@ export default defineComponent({ } | null); return configMan.isConfigOwnerAdmin(currentUser); }); - const modeToolTips = { - Creating: { - rectangle: 'Drag to draw rectangle. Press ESC to exit.', - Polygon: 'Click to place vertices. Right click to close.', - LineString: 'Click to place head/tail points.', - Time: 'Automatically creating Time', - Mask: 'Create a Segmentation Mask', - }, - Editing: { - rectangle: 'Drag vertices to resize the rectangle', - Polygon: 'Drag midpoints to create new vertices. Click vertices to select for deletion.', - LineString: 'Click endpoints to select for deletion.', - Time: 'Use the keyframe indicator to modify the time annotation, Delete to return to rectangle annotations', - Mask: 'Edit the Segmentation Mask', - }, - }; + const modeToolTips = EDITING_MODE_INSTRUCTIONS; const editBlackColorScale = ref(false); const editWhiteColorScale = ref(false); @@ -408,6 +399,9 @@ export default defineComponent({ mousetrap, editingHeader, modeToolTips, + groupEditInstruction: GROUP_EDIT_INSTRUCTION, + multiSelectInstruction: MULTI_SELECT_INSTRUCTION, + idleInstruction: IDLE_INSTRUCTION, updateMaskOpacity, maskOpacity, maskCacheSeconds, @@ -452,16 +446,15 @@ export default defineComponent({ style="line-height: 1.22em; font-size: 10px;" > - Editing group. Add or remove tracks. Esc. to exit. + {{ groupEditInstruction }} - Multi-select in progress. Editing is disabled. - Select additional tracks to merge or group. + {{ multiSelectInstruction }} {{ modeToolTips[editingDetails][editingMode] }} - Right click on an annotation to edit + {{ idleInstruction }} diff --git a/client/dive-common/components/configurationEditors/UISettings/UIContextBar.vue b/client/dive-common/components/configurationEditors/UISettings/UIContextBar.vue index f98ea2e1..2573bcc7 100644 --- a/client/dive-common/components/configurationEditors/UISettings/UIContextBar.vue +++ b/client/dive-common/components/configurationEditors/UISettings/UIContextBar.vue @@ -1,9 +1,10 @@ + + + + diff --git a/client/src/components/annotators/ImageAnnotator.vue b/client/src/components/annotators/ImageAnnotator.vue index 5c17ea68..2ddbafcd 100644 --- a/client/src/components/annotators/ImageAnnotator.vue +++ b/client/src/components/annotators/ImageAnnotator.vue @@ -4,6 +4,8 @@ import { } from 'vue'; import { SetTimeFunc } from '../../use/useTimeObserver'; import { injectCameraInitializer } from './useMediaController'; +import AnnotatorImageCursor from './AnnotatorImageCursor.vue'; +import useAnnotatorImageCursor from './useAnnotatorImageCursor'; export interface ImageDataItem { url: string; @@ -21,6 +23,7 @@ function loadImageFunc(imageDataItem: ImageDataItem, img: HTMLImageElement) { } export default defineComponent({ name: 'ImageAnnotator', + components: { AnnotatorImageCursor }, props: { imageData: { type: Array as PropType, @@ -69,6 +72,13 @@ export default defineComponent({ // eslint-disable-next-line @typescript-eslint/no-use-before-define seek, pause, play, setVolume: unimplemented, setSpeed: unimplemented, }); + const { playbackCursor } = useAnnotatorImageCursor( + toRef(data, 'imageCursor'), + toRef(data, 'cursor'), + toRef(data, 'imageCursorEditing'), + ); + const imageCursorIcon = toRef(data, 'imageCursor'); + const imageCursorEditing = toRef(data, 'imageCursorEditing'); data.maxFrame = props.imageData.length - 1; // Below are configuration settings we can set until we decide on good numbers to utilize. let local = { @@ -374,6 +384,9 @@ export default defineComponent({ imageCursorRef: imageCursor, containerRef: container, cursorHandler, + playbackCursor, + imageCursorIcon, + imageCursorEditing, }; }, }); @@ -412,12 +425,16 @@ export default defineComponent({ ref="imageCursorRef" class="imageCursor" > - {{ data.imageCursor }} +