diff --git a/client/dive-common/components/AnnotationVisibilityMenu.vue b/client/dive-common/components/AnnotationVisibilityMenu.vue index f4e1a4857..10fe3e24a 100644 --- a/client/dive-common/components/AnnotationVisibilityMenu.vue +++ b/client/dive-common/components/AnnotationVisibilityMenu.vue @@ -29,8 +29,12 @@ export default defineComponent({ type: Object as PropType<{ before: number; after: number }>, required: true, }, + showUserCreatedIcon: { + type: Boolean, + default: true, + }, }, - emits: ['set-annotation-state', 'update:tail-settings'], + emits: ['set-annotation-state', 'update:tail-settings', 'update:show-user-created-icon'], setup(props, { emit }) { const STORAGE_KEY = 'annotationVisibilityMenu.expanded'; @@ -116,6 +120,10 @@ export default defineComponent({ emit('update:tail-settings', settings); }; + const toggleShowUserCreatedIcon = () => { + emit('update:show-user-created-icon', !props.showUserCreatedIcon); + }; + return { isExpanded, viewButtons, @@ -123,6 +131,7 @@ export default defineComponent({ toggleVisible, toggleExpanded, updateTailSettings, + toggleShowUserCreatedIcon, }; }, }); @@ -166,6 +175,7 @@ export default defineComponent({ :color="button.active ? 'grey darken-2' : ''" class="mx-1 mode-button" small + @click.stop @click="button.click" > {{ button.icon }} @@ -173,6 +183,16 @@ export default defineComponent({ {{ button.description }} + @@ -246,16 +266,53 @@ export default defineComponent({ mdi-chevron-left - - {{ button.icon }} - + + + + + + + + {{ button.icon }} + + - + {{ pair[0] }} @@ -96,9 +100,28 @@ export default defineComponent({ > {{ pair[1].toFixed(4) }} + + + + This annotation has been modified by a user + + , default: () => ({ before: 20, after: 10 }), }, + showUserCreatedIcon: { + type: Boolean, + default: true, + }, }, - emits: ['set-annotation-state', 'update:tail-settings'], + emits: ['set-annotation-state', 'update:tail-settings', 'update:show-user-created-icon'], setup(props, { emit }) { const toolTimeTimeout = ref(null); const STORAGE_KEY = 'editorMenu.editButtonsExpanded'; @@ -325,8 +329,10 @@ export default defineComponent({ diff --git a/client/dive-common/components/TrackDetailsPanel.vue b/client/dive-common/components/TrackDetailsPanel.vue index 43dabda37..6ed235e5c 100644 --- a/client/dive-common/components/TrackDetailsPanel.vue +++ b/client/dive-common/components/TrackDetailsPanel.vue @@ -23,6 +23,7 @@ import { useSelectedCamera, } from 'vue-media-annotator/provides'; import { Attribute } from 'vue-media-annotator/use/AttributeTypes'; +import type Track from 'src/track'; import TrackItem from 'vue-media-annotator/components/TrackItem.vue'; import TooltipBtn from 'vue-media-annotator/components/TooltipButton.vue'; import TypePicker from 'vue-media-annotator/components/TypePicker.vue'; @@ -110,14 +111,24 @@ export default defineComponent({ if (multiSelectList.value.length > 0) { return multiSelectList.value.map( (trackId) => cameraStore.getAnyPossibleTrack(trackId), - ).filter((t) => t !== undefined); + ).filter((t): t is Track => t !== undefined); } if (selectedTrackIdRef.value !== null) { - return [cameraStore.getAnyTrack(selectedTrackIdRef.value)]; + const track = cameraStore.getAnyTrack(selectedTrackIdRef.value); + return track ? [track] : []; } return []; }); + const isUserModified = computed(() => { + if (selectedTrackList.value.length === 1) { + const track = selectedTrackList.value[0]; + const [feature] = track.getFeature(frameRef.value); + return feature?.attributes?.userModified === true; + } + return false; + }); + function setEditIndividual(attribute: Attribute | null) { editIndividual.value = attribute; } @@ -231,6 +242,14 @@ export default defineComponent({ }); } + function setTrackType(type: string) { + const track = selectedTrackList.value[0]; + // Find the confidence value for this type in the track's confidence pairs + const existingPair = track.confidencePairs.find(([t]) => t === type); + const confidenceVal = existingPair ? existingPair[1] : 1; + track.setType(type, confidenceVal); + } + return { selectedTrackIdRef, editingGroupIdRef, @@ -249,6 +268,7 @@ export default defineComponent({ frameRef, /* Selected */ selectedTrackList, + isUserModified, multiSelectList, multiSelectInProgress, editingMultiTrack, @@ -270,6 +290,7 @@ export default defineComponent({ toggleMerge, unstageFromMerge, updateSelectedTracksType, + setTrackType, }; }, }); @@ -574,7 +595,8 @@ export default defineComponent({ flatten(selectedTrackList.map((t) => t.confidencePairs)).sort((a, b) => b[1] - a[1]) " :disabled="selectedTrackList.length > 1" - @set-type="selectedTrackList[0].setType($event)" + :user-modified="isUserModified" + @set-type="setTrackType($event)" /> diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index adab2e24f..930bc5379 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -97,6 +97,7 @@ const defaultSettings: AnnotationSettings = { multiBounds: false, transition: false, }, + showUserCreatedIcon: false, }, timelineCountSettings: { totalCount: true, diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index aaeb0db6a..fb1333d8b 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -400,7 +400,10 @@ export default function useModeManager({ const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value); if (track) { // Determines if we are creating a new Detection - const { interpolate } = track.canInterpolate(frameNum); + const { interpolate, features } = track.canInterpolate(frameNum); + const [real] = features; + // If there's already a keyframe at this frame, we're editing an existing annotation + const isEditingExisting = real !== null && real.keyframe; track.setFeature({ frame: frameNum, @@ -409,6 +412,11 @@ export default function useModeManager({ keyframe: true, interpolate: _shouldInterpolate(interpolate), }); + // Mark as user-modified if editing existing annotation (as detection attribute) + // Skip if track is userCreated (user-created tracks don't need userModified on every detection) + if (isEditingExisting && track.attributes?.userCreated !== true) { + track.setFeatureAttribute(frameNum, 'userModified', true); + } newTrackSettingsAfterLogic(track); } } @@ -507,6 +515,9 @@ export default function useModeManager({ } // Update the state of the track in the trackstore. if (somethingChanged) { + // If there's already a keyframe at this frame, we're editing an existing annotation + const isEditingExisting = real !== null && real.keyframe; + track.setFeature({ frame: frameNum, flick: flickNum, @@ -522,6 +533,12 @@ export default function useModeManager({ })), )); + // Mark as user-modified if editing existing annotation (as detection attribute) + // Skip if track is userCreated (user-created tracks don't need userModified on every detection) + if (isEditingExisting && track.attributes?.userCreated !== true) { + track.setFeatureAttribute(frameNum, 'userModified', true); + } + // Only perform "initialization" after the first shape. // Treat this as a completed annotation if eventType is editing // Or none of the recieps reported that they were unfinished. diff --git a/client/src/TrackStore.ts b/client/src/TrackStore.ts index 860afa252..0378efc2f 100644 --- a/client/src/TrackStore.ts +++ b/client/src/TrackStore.ts @@ -13,6 +13,7 @@ export default class TrackStore extends BaseAnnotationStore { begin: frame, end: frame, confidencePairs: [[defaultType, 1]], + attributes: { userCreated: true }, }); this.insert(track, { afterId }); this.markChangesPending({ action: 'upsert', track, cameraName: this.cameraName }); diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 8b88d363b..ebf296365 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -126,11 +126,13 @@ export default defineComponent({ typeStyling: typeStylingRef, }, trackStore); + const showUserCreatedIconRef = computed(() => annotatorPrefs.value.showUserCreatedIcon ?? true); const textLayer = new TextLayer({ annotator, stateStyling: trackStyleManager.stateStyles, typeStyling: typeStylingRef, formatter: props.formatTextRow, + showUserCreatedIcon: showUserCreatedIconRef, }); const attributeBoxLayer = new AttributeBoxLayer({ diff --git a/client/src/layers/AnnotationLayers/TextLayer.ts b/client/src/layers/AnnotationLayers/TextLayer.ts index b32a73fdc..7b7de21dd 100644 --- a/client/src/layers/AnnotationLayers/TextLayer.ts +++ b/client/src/layers/AnnotationLayers/TextLayer.ts @@ -1,3 +1,4 @@ +import { Ref } from 'vue'; import { TypeStyling } from '../../StyleManager'; import BaseLayer, { BaseLayerParams, LayerStyle } from '../BaseLayer'; import { FrameDataTrack } from '../LayerTypes'; @@ -16,10 +17,11 @@ export interface TextData { } export type FormatTextRow = ( - annotation: FrameDataTrack, typeStyling?: TypeStyling) => TextData[] | null; + annotation: FrameDataTrack, typeStyling?: TypeStyling, showUserCreatedIcon?: boolean) => TextData[] | null; interface TextLayerParams { formatter?: FormatTextRow; + showUserCreatedIcon?: Ref; } /** @@ -31,6 +33,7 @@ interface TextLayerParams { function defaultFormatter( annotation: FrameDataTrack, typeStyling?: TypeStyling, + showUserCreatedIcon: boolean = true, ): TextData[] | null { if (annotation.features && annotation.features.bounds) { const { bounds } = annotation.features; @@ -50,14 +53,18 @@ function defaultFormatter( const [type, confidence] = confidencePairs[i]; let text = ''; + const userModified = annotation.features?.attributes?.userModified === true; + const userCreated = annotation.track.attributes?.userCreated === true; + // Show pencil icon if detection is userModified OR if track is userCreated, and showUserCreatedIcon is true + const modifiedIndicator = (showUserCreatedIcon && (userModified || userCreated)) ? ' ✏️' : ''; if (typeStyling) { const { showLabel, showConfidence } = typeStyling.labelSettings(type); if (showLabel && !showConfidence) { - text = type; + text = type + modifiedIndicator; } else if (showConfidence && !showLabel) { - text = `${confidence.toFixed(2)}`; + text = `${confidence.toFixed(2)}${modifiedIndicator}`; } else if (showConfidence && showLabel) { - text = `${type}: ${confidence.toFixed(2)}`; + text = `${type}: ${confidence.toFixed(2)}${modifiedIndicator}`; } } arr.push({ @@ -100,8 +107,11 @@ function defaultFormatter( export default class TextLayer extends BaseLayer { formatter: FormatTextRow; + showUserCreatedIcon: Ref; + constructor(params: BaseLayerParams & TextLayerParams) { super(params); + this.showUserCreatedIcon = params.showUserCreatedIcon || { value: true } as Ref; this.formatter = params.formatter || defaultFormatter; } @@ -119,8 +129,9 @@ export default class TextLayer extends BaseLayer { formatData(frameData: FrameDataTrack[]) { const arr = [] as TextData[]; const typeStyling = this.typeStyling.value; + const showIcon = this.showUserCreatedIcon.value; frameData.forEach((track: FrameDataTrack) => { - const formatted = this.formatter(track, typeStyling); + const formatted = this.formatter(track, typeStyling, showIcon); if (formatted !== null) { arr.push(...formatted); } diff --git a/client/src/types.ts b/client/src/types.ts index e66f8276b..15893d69f 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -12,5 +12,6 @@ export interface AnnotatorPreferences { enabled?: boolean; transition?: false | number; multiBounds?: false | number; - } + }; + showUserCreatedIcon?: boolean; }