diff --git a/client/dive-common/components/AnnotationVisibilityMenu.vue b/client/dive-common/components/AnnotationVisibilityMenu.vue index 677063136..985b0c91e 100644 --- a/client/dive-common/components/AnnotationVisibilityMenu.vue +++ b/client/dive-common/components/AnnotationVisibilityMenu.vue @@ -33,8 +33,17 @@ export default defineComponent({ type: Boolean, default: true, }, + additionalPointSettings: { + type: Object as PropType<{ showLabels: boolean; sizePercent: number }>, + default: () => ({ showLabels: true, sizePercent: 100 }), + }, }, - emits: ['set-annotation-state', 'update:tail-settings', 'update:show-user-created-icon'], + emits: [ + 'set-annotation-state', + 'update:tail-settings', + 'update:show-user-created-icon', + 'update:additional-point-settings', + ], setup(props, { emit }) { const STORAGE_KEY = 'annotationVisibilityMenu.expanded'; @@ -96,6 +105,14 @@ export default defineComponent({ description: 'Head Tail', click: () => toggleVisible('LineString'), }, + { + id: 'additionalPoints', + type: 'additionalPoints', + active: isVisible('additionalPoints'), + icon: 'mdi-vector-point', + description: 'Additional Points', + click: () => toggleVisible('additionalPoints'), + }, { id: 'text', type: 'text', @@ -124,6 +141,21 @@ export default defineComponent({ emit('update:show-user-created-icon', !props.showUserCreatedIcon); }; + const toggleAdditionalPointShowLabels = () => { + emit('update:additional-point-settings', { + ...props.additionalPointSettings, + showLabels: !props.additionalPointSettings.showLabels, + }); + }; + + const updateAdditionalPointSize = (event: Event) => { + const value = Number.parseInt((event.target as HTMLInputElement).value, 10); + emit('update:additional-point-settings', { + ...props.additionalPointSettings, + sizePercent: value, + }); + }; + return { isExpanded, viewButtons, @@ -132,6 +164,8 @@ export default defineComponent({ toggleExpanded, updateTailSettings, toggleShowUserCreatedIcon, + toggleAdditionalPointShowLabels, + updateAdditionalPointSize, }; }, }); @@ -195,6 +229,36 @@ export default defineComponent({ /> + + + + +
+ + + + + + + + + +
+ + + + - {{ editingMode }} + {{ editingMode === 'additionalPoints' ? 'point' : editingMode }} unselected , + default: () => ({ showLabels: true, sizePercent: 100 }), + }, + selectedKey: { + type: String, + default: '', + }, + /** All additionalPoints keys seen on any track at the current frame (current camera). */ + allAdditionalPointKeys: { + type: Array as PropType, + default: () => [], + }, }, - emits: ['set-annotation-state', 'update:tail-settings', 'update:show-user-created-icon'], + emits: [ + 'set-annotation-state', + 'update:tail-settings', + 'update:show-user-created-icon', + 'update:additional-point-settings', + ], setup(props, { emit }) { const toolTimeTimeout = ref(null); const STORAGE_KEY = 'editorMenu.editButtonsExpanded'; @@ -90,14 +109,20 @@ export default defineComponent({ 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.', + additionalPoints: 'Click to place a named point inside the selected bounding box.', }, 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.', + additionalPoints: 'Drag to move the named point. Delete to remove the current point name.', }, }; + const additionalPointName = ref(props.selectedKey || ''); + /** v-combobox @change runs when v-model is set programmatically; skip spurious commits (e.g. "New Point"). */ + const suppressAdditionalPointCommit = ref(false); + const editButtons = computed((): ButtonData[] => { const em = props.editingMode; return [ @@ -131,6 +156,31 @@ export default defineComponent({ ...r.mousetrap(), ], })), + { + id: 'additionalPoints', + icon: 'mdi-vector-point', + active: props.editingTrack && em === 'additionalPoints', + description: 'Additional Points', + mousetrap: [{ + bind: '4', + handler: () => { + const key = additionalPointName.value.trim() + || props.selectedKey + || (props.allAdditionalPointKeys && props.allAdditionalPointKeys[0]) + || 'point'; + additionalPointName.value = key; + emit('set-annotation-state', { editing: 'additionalPoints', key }); + }, + }], + click: () => { + const key = additionalPointName.value.trim() + || props.selectedKey + || (props.allAdditionalPointKeys && props.allAdditionalPointKeys[0]) + || 'point'; + additionalPointName.value = key; + emit('set-annotation-state', { editing: 'additionalPoints', key }); + }, + }, ]; }); @@ -184,6 +234,66 @@ export default defineComponent({ } }); + watch(() => props.selectedKey, (val) => { + if (val && val !== additionalPointName.value) { + additionalPointName.value = val; + } + }); + + const commitAdditionalPointName = () => { + if (suppressAdditionalPointCommit.value) { + return; + } + const key = additionalPointName.value.trim(); + if (!key) { + return; + } + additionalPointName.value = key; + if ( + props.editingMode === 'additionalPoints' + && key === (props.selectedKey || '').trim() + ) { + return; + } + emit('set-annotation-state', { editing: 'additionalPoints', key }); + }; + + const additionalPointNameOptions = computed(() => { + const options = new Set(props.allAdditionalPointKeys || []); + const selected = props.selectedKey?.trim(); + if (selected) { + options.add(selected); + } + const cur = typeof additionalPointName.value === 'string' + ? additionalPointName.value.trim() + : ''; + if (cur) { + options.add(cur); + } + return Array.from(options).sort((a, b) => a.localeCompare(b)); + }); + + const createNewPointName = () => { + const existing = new Set(additionalPointNameOptions.value); + let index = 1; + let candidate = `point-${index}`; + while (existing.has(candidate)) { + index += 1; + candidate = `point-${index}`; + } + suppressAdditionalPointCommit.value = true; + // Emit first so parent `selectedKey` updates before any combobox @change runs. + emit('set-annotation-state', { + editing: 'additionalPoints', + key: candidate, + skipAdditionalPointRename: true, + }); + additionalPointName.value = candidate; + nextTick(() => { + suppressAdditionalPointCommit.value = false; + }); + }; + return { modeToolTips, editButtons, @@ -194,6 +304,10 @@ export default defineComponent({ toggleEditButtonsExpanded, activeEditButton, editButtonsMenuKey, + additionalPointName, + commitAdditionalPointName, + additionalPointNameOptions, + createNewPointName, }; }, }); @@ -272,7 +386,7 @@ export default defineComponent({ > {{ button.icon }} + + + + mdi-plus + + Point + @@ -330,9 +470,11 @@ export default defineComponent({ :visible-modes="visibleModes" :tail-settings="tailSettings" :show-user-created-icon="showUserCreatedIcon" + :additional-point-settings="additionalPointSettings" @set-annotation-state="$emit('set-annotation-state', $event)" @update:tail-settings="$emit('update:tail-settings', $event)" @update:show-user-created-icon="$emit('update:show-user-created-icon', $event)" + @update:additional-point-settings="$emit('update:additional-point-settings', $event)" />
diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 8d562df15..6bae68332 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -279,6 +279,30 @@ export default defineComponent({ return multiSelectList.value; }); + /** Every `additionalPoints` object key present on any track at the current frame (active camera). */ + const allAdditionalPointKeys = computed(() => { + // Re-compute when edits mark the dataset dirty (feature updates use this path). + const dirtyTick = pendingSaveCount.value; + const sub = cameraStore.camMap.value.get(selectedCamera.value); + const store = sub?.trackStore; + if (!store) { + return [] as string[]; + } + const frame = time.frame.value; + const keys = new Set(); + store.annotationMap.forEach((track) => { + const [feature] = (track as Track).getFeature(frame); + const ap = feature?.additionalPoints; + if (!ap) { + return; + } + Object.keys(ap).forEach((k) => keys.add(k)); + }); + return Array.from(keys) + .sort((a, b) => a.localeCompare(b)) + .filter(() => dirtyTick >= 0); + }); + const { lineChartData } = useLineChart({ enabledTracks: trackFilters.enabledAnnotations, typeStyling: trackStyleManager.typeStyling, @@ -865,6 +889,7 @@ export default defineComponent({ selectedTrackId, editingGroupId, selectedKey, + allAdditionalPointKeys, trackFilters, videoUrl, visibleModes, @@ -999,8 +1024,11 @@ export default defineComponent({ multiSelectActive, editingDetails, groupEditActive: editingGroupId !== null, + selectedKey, + allAdditionalPointKeys, }" :tail-settings.sync="clientSettings.annotatorPreferences.trackTails" + :additional-point-settings.sync="clientSettings.annotatorPreferences.additionalPoints" :show-user-created-icon.sync="clientSettings.annotatorPreferences.showUserCreatedIcon" @set-annotation-state="handler.setAnnotationState" @exit-edit="handler.trackAbort" diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index 930bc5379..4e59ed02d 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -92,6 +92,10 @@ const defaultSettings: AnnotationSettings = { before: 20, after: 10, }, + additionalPoints: { + showLabels: true, + sizePercent: 100, + }, lockedCamera: { enabled: false, multiBounds: false, diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index a64eca011..8846f24d2 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -2,7 +2,7 @@ import { computed, Ref, reactive, ref, onBeforeUnmount, toRef, } from 'vue'; import { uniq, flatMapDeep, flattenDeep } from 'lodash'; -import Track, { TrackId } from 'vue-media-annotator/track'; +import Track, { AdditionalPoint, TrackId } from 'vue-media-annotator/track'; import { RectBounds, updateBounds, @@ -50,6 +50,8 @@ interface SetAnnotationStateArgs { editing?: EditAnnotationTypes; key?: string; recipeName?: string; + /** When true, changing the point name does not migrate data from the previous label (e.g. "New Point"). */ + skipAdditionalPointRename?: boolean; } /** * The point of this composition function is to define and manage the transition betwee @@ -76,7 +78,7 @@ export default function useModeManager({ let creating = false; const { prompt } = usePrompt(); const annotationModes = reactive({ - visible: ['rectangle', 'Polygon', 'LineString', 'text'] as VisibleAnnotationTypes[], + visible: ['rectangle', 'Polygon', 'LineString', 'text', 'additionalPoints'] as VisibleAnnotationTypes[], editing: 'rectangle' as EditAnnotationTypes, }); const trackSettings = toRef(clientSettings, 'trackSettings'); @@ -156,6 +158,16 @@ export default function useModeManager({ return 'Creating'; } if (annotationModes.editing === 'rectangle') { return 'Editing'; + } if (annotationModes.editing === 'additionalPoints') { + if (!feature.bounds?.length) { + return 'disabled'; + } + const additionalPoints = feature.additionalPoints || {}; + const key = selectedKey.value; + if (!key) { + return 'Creating'; + } + return (additionalPoints[key]?.length || 0) > 0 ? 'Editing' : 'Creating'; } return (feature.geometry?.features.filter((item) => item.geometry.type === annotationModes.editing).length ? 'Editing' : 'Creating'); } @@ -449,6 +461,50 @@ export default function useModeManager({ key?: string, preventInterrupt?: () => void, ) { + if (annotationModes.editing === 'additionalPoints') { + if (data.geometry.type !== 'Point') { + return; + } + if (selectedTrackId.value === null) { + return; + } + const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value); + if (!track) { + return; + } + const label = (key || selectedKey.value || '').trim(); + if (!label) { + return; + } + const [x, y] = data.geometry.coordinates; + const roundedPoint: AdditionalPoint = { + coordinates: [Math.round(x), Math.round(y)], + label, + }; + const { features, interpolate } = track.canInterpolate(frameNum); + const [real] = features; + const currentFeature = real || null; + if (!currentFeature?.bounds) { + return; + } + if (!currentFeature.keyframe) { + track.setFeature({ + frame: frameNum, + flick: flickNum, + keyframe: true, + interpolate: _shouldInterpolate(interpolate), + bounds: currentFeature.bounds, + }); + } + const current = track.getAdditionalPoints(frameNum, label) as AdditionalPoint[]; + if (current.length > 0) { + track.updateAdditionalPoint(frameNum, label, 0, roundedPoint); + } else { + track.addAdditionalPoint(frameNum, label, roundedPoint); + } + _nudgeEditingCanary(); + return; + } /** * Declare aggregate update collector. Each recipe * will have the opportunity to modify this object. @@ -574,6 +630,10 @@ export default function useModeManager({ /* If any recipes are active, allow them to remove a point */ function handleRemovePoint() { + if (annotationModes.editing === 'additionalPoints') { + handleRemoveAnnotation(); + return; + } if (selectedTrackId.value !== null && selectedFeatureHandle.value !== -1) { const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value); if (track) { @@ -596,6 +656,51 @@ export default function useModeManager({ /* If any recipes are active, remove the geometry they added */ function handleRemoveAnnotation() { + if (annotationModes.editing === 'additionalPoints') { + if (selectedTrackId.value !== null && selectedKey.value) { + const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value); + const { frame } = aggregateController.value; + if (track) { + const deletedKey = selectedKey.value.trim(); + const apBefore = track.getAdditionalPoints(frame.value) as Record; + const keysBefore = Object.keys(apBefore) + .filter((k) => (apBefore[k]?.length || 0) > 0) + .sort((a, b) => a.localeCompare(b)); + const idx = keysBefore.indexOf(deletedKey); + + track.setAdditionalPoints(frame.value, selectedKey.value, []); + _nudgeEditingCanary(); + + const apAfter = track.getAdditionalPoints(frame.value) as Record; + const keysAfter = Object.keys(apAfter) + .filter((k) => (apAfter[k]?.length || 0) > 0) + .sort((a, b) => a.localeCompare(b)); + + let nextKey: string | undefined; + if (idx >= 0) { + // Prefer the next name in the sorted list (same order as the editor combobox). + if (idx + 1 < keysBefore.length) { + const candidate = keysBefore[idx + 1]; + if (keysAfter.includes(candidate)) { + nextKey = candidate; + } + } + // Was last (or next missing): select the previous name in the list. + if (nextKey === undefined && idx > 0) { + const candidate = keysBefore[idx - 1]; + if (keysAfter.includes(candidate)) { + nextKey = candidate; + } + } + } + if (nextKey === undefined && keysAfter.length > 0) { + [nextKey] = keysAfter; + } + _selectKey(nextKey); + } + } + return; + } if (selectedTrackId.value !== null) { const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value); if (track) { @@ -721,14 +826,39 @@ export default function useModeManager({ } function handleSetAnnotationState({ - visible, editing, key, recipeName, + visible, editing, key, recipeName, skipAdditionalPointRename, }: SetAnnotationStateArgs) { if (visible) { annotationModes.visible = visible; } if (editing) { + const prevEditing = annotationModes.editing; annotationModes.editing = editing; - _selectKey(key); + if (editing === 'additionalPoints') { + const newKey = ( + typeof key === 'string' && key.trim() !== '' + ? key.trim() + : (selectedKey.value || 'point') + ).trim() || 'point'; + const oldKey = (selectedKey.value || '').trim(); + if ( + !skipAdditionalPointRename + && prevEditing === 'additionalPoints' + && oldKey + && newKey + && oldKey !== newKey + && selectedTrackId.value !== null + ) { + const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value); + const { frame } = aggregateController.value; + if (track?.renameAdditionalPointsLabel(frame.value, oldKey, newKey)) { + _nudgeEditingCanary(); + } + } + _selectKey(newKey); + } else { + _selectKey(key); + } handleSelectTrack(selectedTrackId.value, true); recipes.forEach((r) => { if (recipeName !== r.name) { diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 675dcdcd5..7f3097189 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -12,6 +12,7 @@ import PointLayer from '../layers/AnnotationLayers/PointLayer'; import LineLayer from '../layers/AnnotationLayers/LineLayer'; import TailLayer from '../layers/AnnotationLayers/TailLayer'; import OverlapLayer from '../layers/AnnotationLayers/OverlapLayer'; +import AdditionalPointLayer from '../layers/AnnotationLayers/AdditionalPointLayer'; import EditAnnotationLayer, { EditAnnotationTypes } from '../layers/EditAnnotationLayer'; import { FrameDataTrack } from '../layers/LayerTypes'; @@ -125,6 +126,11 @@ export default defineComponent({ stateStyling: trackStyleManager.stateStyles, typeStyling: typeStylingRef, }, trackStore); + const additionalPointLayer = new AdditionalPointLayer({ + annotator, + stateStyling: trackStyleManager.stateStyles, + typeStyling: typeStylingRef, + }); const showUserCreatedIconRef = computed(() => annotatorPrefs.value.showUserCreatedIcon ?? true); const textLayer = new TextLayer({ @@ -315,6 +321,22 @@ export default defineComponent({ } else { pointLayer.disable(); } + if (visibleModes.includes('additionalPoints')) { + const apPrefs = annotatorPrefs.value.additionalPoints; + additionalPointLayer.updateDisplaySettings( + apPrefs?.showLabels ?? true, + apPrefs?.sizePercent ?? 100, + ); + additionalPointLayer.setAdditionalPointEditContext( + editingTrack === 'additionalPoints', + selectedTrackId, + selectedKey, + ); + additionalPointLayer.changeData(frameData); + } else { + additionalPointLayer.setAdditionalPointEditContext(false, null, ''); + additionalPointLayer.disable(); + } if (visibleModes.includes('text')) { textLayer.changeData(frameData); attributeBoxLayer.changeData(frameData); @@ -346,9 +368,54 @@ export default defineComponent({ } if (editingTracks.length) { if (editingTrack) { - editAnnotationLayer.setType(editingTrack); - editAnnotationLayer.setKey(selectedKey); - editAnnotationLayer.changeData(editingTracks); + if (editingTrack === 'additionalPoints') { + const additionalPointEditingTrack = editingTracks.map((trackFrame) => { + if (!trackFrame.features) { + return trackFrame; + } + const additionalPointsForKey = ( + trackFrame.features.additionalPoints?.[selectedKey] + || [] + ); + const firstPoint = additionalPointsForKey[0]; + if (!firstPoint) { + return { + ...trackFrame, + features: { + ...trackFrame.features, + geometry: undefined, + }, + }; + } + const geometry: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: firstPoint.coordinates, + }, + properties: { + key: selectedKey, + }, + }], + }; + return { + ...trackFrame, + features: { + ...trackFrame.features, + geometry, + }, + }; + }); + editAnnotationLayer.setType('additionalPoints'); + editAnnotationLayer.setKey(selectedKey); + editAnnotationLayer.changeData(additionalPointEditingTrack); + } else { + editAnnotationLayer.setType(editingTrack); + editAnnotationLayer.setKey(selectedKey); + editAnnotationLayer.changeData(editingTracks); + } } } else { editAnnotationLayer.disable(); @@ -385,6 +452,7 @@ export default defineComponent({ selectedTrackIdRef, multiSeletListRef, visibleModesRef, + selectedKeyRef, typeStylingRef, toRef(props, 'colorBy'), selectedCamera, @@ -457,6 +525,15 @@ export default defineComponent({ polyAnnotationLayer.bus.$on('annotation-clicked', Clicked); polyAnnotationLayer.bus.$on('annotation-right-clicked', Clicked); polyAnnotationLayer.bus.$on('annotation-ctrl-clicked', Clicked); + additionalPointLayer.bus.$on('annotation-right-clicked', (trackId: number, key: string) => { + if (selectedCamera.value !== props.camera) { + return; + } + editAnnotationLayer.disable(); + handler.trackSelect(trackId, true); + handler.setAnnotationState({ editing: 'additionalPoints', key }); + handler.selectFeatureHandle(0, key); + }); editAnnotationLayer.bus.$on('update:geojson', ( mode: 'in-progress' | 'editing', geometryCompleteEvent: boolean, diff --git a/client/src/layers/AnnotationLayers/AdditionalPointLayer.ts b/client/src/layers/AnnotationLayers/AdditionalPointLayer.ts new file mode 100644 index 000000000..432d8b1a3 --- /dev/null +++ b/client/src/layers/AnnotationLayers/AdditionalPointLayer.ts @@ -0,0 +1,186 @@ +/* eslint-disable class-methods-use-this */ +import geo, { GeoEvent } from 'geojs'; +import BaseLayer, { LayerStyle } from '../BaseLayer'; +import { FrameDataTrack } from '../LayerTypes'; + +interface AdditionalPointData { + trackId: number; + selected: boolean; + editing: boolean | string; + styleType: [string, number] | null; + key: string; + label: string; + x: number; + y: number; + /** True when this is the named point being edited on the selected track. */ + keySelected: boolean; +} + +export default class AdditionalPointLayer extends BaseLayer { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + textFeatureLayer: any; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + textLayer: any; + + private additionalPointEditActive = false; + + private additionalPointEditTrackId: number | null = null; + + private additionalPointEditKey = ''; + + /** When false, point labels are hidden (markers still draw). */ + private showLabels = true; + + /** Scales default radii; 100 = same as legacy sizes. */ + private sizePercent = 100; + + private pointRadiusBase(data: AdditionalPointData): number { + if (data.keySelected) { + return data.selected ? 11 : 9; + } + return data.selected ? 8 : 6; + } + + /** Pixel radius after size slider. */ + pointRadius(data: AdditionalPointData): number { + return this.pointRadiusBase(data) * (this.sizePercent / 100); + } + + updateDisplaySettings(showLabels: boolean, sizePercent: number) { + this.showLabels = showLabels; + this.sizePercent = sizePercent; + } + + /** + * Highlight the point that matches the current additional-points edit selection. + */ + setAdditionalPointEditContext(active: boolean, trackId: number | null, key: string) { + this.additionalPointEditActive = active; + this.additionalPointEditTrackId = trackId; + this.additionalPointEditKey = key || ''; + } + + initialize() { + this.tryInitialize(); + super.initialize(); + } + + tryInitialize(): boolean { + if (this.featureLayer && this.textFeatureLayer) { + return true; + } + try { + const pointLayer = this.annotator.geoViewerRef.value.createLayer('feature', { + features: ['point'], + }); + this.featureLayer = pointLayer + .createFeature('point', { selectionAPI: true }) + .geoOn(geo.event.feature.mouseclick, (e: GeoEvent) => { + if (e.mouse.buttonsDown.right) { + this.bus.$emit('annotation-right-clicked', e.data.trackId, e.data.key); + } + }); + + this.textLayer = this.annotator.geoViewerRef.value.createLayer('feature', { + features: ['text'], + }); + this.textFeatureLayer = this.textLayer + .createFeature('text') + .text((data: AdditionalPointData) => (this.showLabels ? data.label : '')) + .position((data: AdditionalPointData) => ({ x: data.x, y: data.y })); + this.textFeatureLayer.style({ + color: (data: AdditionalPointData) => { + if (data.selected) { + return this.stateStyling.selected.color; + } + if (data.styleType) { + return this.typeStyling.value.color(data.styleType[0]); + } + return this.typeStyling.value.color(''); + }, + offset: (data: AdditionalPointData) => { + const r = this.pointRadius(data); + const pad = 6; + return { x: r + pad, y: -(r + pad) }; + }, + fontSize: '12px', + }); + return true; + } catch { + // Some playback paths initialize layers before the GeoJS canvas is ready. + this.featureLayer = null; + this.textLayer = null; + this.textFeatureLayer = null; + return false; + } + } + + formatData(frameDataTracks: FrameDataTrack[]): AdditionalPointData[] { + const arr: AdditionalPointData[] = []; + frameDataTracks.forEach((frameData: FrameDataTrack) => { + const additionalPoints = frameData.features?.additionalPoints; + if (!additionalPoints) { + return; + } + Object.entries(additionalPoints).forEach(([label, points]) => { + points.forEach((point) => { + const keySelected = !!( + this.additionalPointEditActive + && this.additionalPointEditTrackId === frameData.track.id + && label === this.additionalPointEditKey + ); + arr.push({ + trackId: frameData.track.id, + selected: frameData.selected, + editing: frameData.editing, + styleType: frameData.styleType, + key: label, + label: point.label || label, + x: point.coordinates[0], + y: point.coordinates[1], + keySelected, + }); + }); + }); + }); + return arr; + } + + createStyle(): LayerStyle { + return { + ...super.createStyle(), + fill: true, + fillColor: (data: AdditionalPointData) => { + if (data.selected) { + return this.stateStyling.selected.color; + } + if (data.styleType) { + return this.typeStyling.value.color(data.styleType[0]); + } + return this.typeStyling.value.color(''); + }, + fillOpacity: 0.9, + radius: (data: AdditionalPointData) => this.pointRadius(data), + strokeWidth: (data: AdditionalPointData) => (data.keySelected ? 4 : 2), + strokeColor: (data: AdditionalPointData) => (data.keySelected ? '#ffeb3b' : '#ffffff'), + }; + } + + redraw(): null { + if (!this.tryInitialize()) { + return null; + } + this.featureLayer.data(this.formattedData).draw(); + this.textFeatureLayer.data(this.formattedData).draw(); + return null; + } + + disable() { + if (!this.tryInitialize()) { + return; + } + this.featureLayer.data([]).draw(); + this.textFeatureLayer.data([]).draw(); + } +} diff --git a/client/src/layers/EditAnnotationLayer.ts b/client/src/layers/EditAnnotationLayer.ts index 4c1204a1a..f5a24383e 100644 --- a/client/src/layers/EditAnnotationLayer.ts +++ b/client/src/layers/EditAnnotationLayer.ts @@ -15,7 +15,7 @@ import { import { FrameDataTrack } from './LayerTypes'; import BaseLayer, { BaseLayerParams, LayerStyle } from './BaseLayer'; -export type EditAnnotationTypes = 'Point' | 'rectangle' | 'Polygon' | 'LineString'; +export type EditAnnotationTypes = 'Point' | 'rectangle' | 'Polygon' | 'LineString' | 'additionalPoints'; interface EditAnnotationLayerParams { type: EditAnnotationTypes; } @@ -32,6 +32,7 @@ const typeMapper = new Map([ ['LineString', 'line'], ['Polygon', 'polygon'], ['Point', 'point'], + ['additionalPoints', 'point'], ['rectangle', 'rectangle'], ]); /** @@ -640,7 +641,11 @@ export default class EditAnnotationLayer extends BaseLayer { */ createStyle(): LayerStyle { const baseStyle = super.createStyle(); - if (this.type === 'rectangle' || this.type === 'Polygon' || this.type === 'LineString') { + if ( + this.type === 'rectangle' + || this.type === 'Polygon' + || this.type === 'LineString' + ) { return { ...baseStyle, fill: false, @@ -671,7 +676,7 @@ export default class EditAnnotationLayer extends BaseLayer { }, }; } - if (this.type === 'Point') { + if (this.type === 'Point' || this.type === 'additionalPoints') { return { handles: false, }; @@ -734,7 +739,7 @@ export default class EditAnnotationLayer extends BaseLayer { }, }; } - if (this.type === 'Point') { + if (this.type === 'Point' || this.type === 'additionalPoints') { return { stroke: false, }; diff --git a/client/src/provides.ts b/client/src/provides.ts index 078b8ae5f..bc54f5165 100644 --- a/client/src/provides.ts +++ b/client/src/provides.ts @@ -143,6 +143,14 @@ export interface Handler { key?: string, preventInterrupt?: () => void, ): void; + /* set annotation view/edit state */ + setAnnotationState(args: { + visible?: VisibleAnnotationTypes[]; + editing?: EditAnnotationTypes; + key?: string; + recipeName?: string; + skipAdditionalPointRename?: boolean; + }): void; /* Remove a whole track */ removeTrack(AnnotationIds: AnnotationId[], forcePromptDisable?: boolean, cameraName?: string): void; @@ -206,6 +214,7 @@ function dummyHandler(handle: (name: string, args: unknown[]) => void): Handler trackAdd(...args) { handle('trackAdd', args); return 0; }, updateRectBounds(...args) { handle('updateRectBounds', args); }, updateGeoJSON(...args) { handle('updateGeoJSON', args); }, + setAnnotationState(...args) { handle('setAnnotationState', args); }, removeTrack(...args) { handle('removeTrack', args); }, removeGroup(...args) { handle('removeGroup', args); }, removePoint(...args) { handle('removePoint', args); }, @@ -304,7 +313,11 @@ function dummyState(): State { }); return { - annotatorPreferences: ref({ trackTails: { before: 20, after: 10 }, lockedCamera: { enabled: false } }), + annotatorPreferences: ref({ + trackTails: { before: 20, after: 10 }, + additionalPoints: { showLabels: true, sizePercent: 100 }, + lockedCamera: { enabled: false }, + }), attributes: ref([]), cameraStore, datasetId: ref(''), diff --git a/client/src/track.ts b/client/src/track.ts index c23750978..570c8d6ed 100644 --- a/client/src/track.ts +++ b/client/src/track.ts @@ -15,6 +15,15 @@ export type TrackId = number; export type TrackSupportedFeature = ( GeoJSON.Point | GeoJSON.Polygon | GeoJSON.LineString | GeoJSON.Point); +/** + * A single point annotation on a detection (separate from geometry head/tail). + * Stored in Feature.additionalPoints by label. + */ +export interface AdditionalPoint { + coordinates: [number, number]; + label?: string; +} + /* Frame feature for both TrackData and Track */ export interface Feature { frame: number; @@ -27,6 +36,8 @@ export interface Feature { attributes?: StringKeyObject & { userAttributes?: StringKeyObject }; head?: [number, number]; tail?: [number, number]; + /** Point annotations keyed by type/label; multiple points per label per detection. */ + additionalPoints?: Record; } /** TrackData is the json schema for Track transport */ @@ -373,6 +384,91 @@ export default class Track extends BaseAnnotation { return false; } + /** + * Get additional point annotations for a frame. If label is given, returns + * only points for that label; otherwise returns the full additionalPoints record. + */ + getAdditionalPoints(frame: number, label?: string): AdditionalPoint[] | Record { + const feature = this.features[frame]; + const points = feature?.additionalPoints; + if (!points) { + return label !== undefined ? [] : {}; + } + if (label !== undefined) { + return points[label] ?? []; + } + return points; + } + + /** + * Set all additional points for a label on a frame. Empty array removes the label key. + */ + setAdditionalPoints(frame: number, label: string, points: AdditionalPoint[]): void { + const feature = this.features[frame]; + if (!feature) { + return; + } + if (!feature.additionalPoints) { + feature.additionalPoints = {}; + } + if (points.length === 0) { + delete feature.additionalPoints[label]; + } else { + feature.additionalPoints[label] = points; + } + this.notify('feature', feature); + } + + /** + * Move all additional points from one label to another on a frame (rename). + * Returns true if data was moved. No-op if labels are equal, old label has no + * points, or new label already has points (caller is switching keys, not renaming). + */ + renameAdditionalPointsLabel(frame: number, oldLabel: string, newLabel: string): boolean { + if (!oldLabel || !newLabel || oldLabel === newLabel) { + return false; + } + const oldPts = this.getAdditionalPoints(frame, oldLabel) as AdditionalPoint[]; + if (!oldPts.length) { + return false; + } + const existingNew = this.getAdditionalPoints(frame, newLabel) as AdditionalPoint[]; + if (existingNew.length > 0) { + return false; + } + const migrated = oldPts.map((p) => ({ ...p, label: newLabel })); + this.setAdditionalPoints(frame, newLabel, migrated); + this.setAdditionalPoints(frame, oldLabel, []); + return true; + } + + /** Append one additional point for a label on a frame. */ + addAdditionalPoint(frame: number, label: string, point: AdditionalPoint): void { + const current = this.getAdditionalPoints(frame, label) as AdditionalPoint[]; + this.setAdditionalPoints(frame, label, [...current, point]); + } + + /** Remove the additional point at index for a label on a frame. */ + removeAdditionalPoint(frame: number, label: string, index: number): void { + const current = this.getAdditionalPoints(frame, label) as AdditionalPoint[]; + if (index < 0 || index >= current.length) { + return; + } + const next = current.slice(0, index).concat(current.slice(index + 1)); + this.setAdditionalPoints(frame, label, next); + } + + /** Replace the additional point at index for a label on a frame. */ + updateAdditionalPoint(frame: number, label: string, index: number, point: AdditionalPoint): void { + const current = this.getAdditionalPoints(frame, label) as AdditionalPoint[]; + if (index < 0 || index >= current.length) { + return; + } + const next = current.slice(); + next[index] = point; + this.setAdditionalPoints(frame, label, next); + } + setFeatureAttribute(frame: number, name: string, value: unknown, user: null | string = null) { if (this.features[frame]) { if (user !== null) { diff --git a/client/src/types.ts b/client/src/types.ts index 15893d69f..ba8691c10 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -8,6 +8,12 @@ export interface AnnotatorPreferences { before: number; after: number; }; + /** Display options for additional (custom) point annotations. */ + additionalPoints?: { + showLabels: boolean; + /** Radius scale vs default, as a percentage (50–200). */ + sizePercent: number; + }; lockedCamera: { enabled?: boolean; transition?: false | number; diff --git a/server/dive_utils/models.py b/server/dive_utils/models.py index 9c18c0e27..cbea4018d 100644 --- a/server/dive_utils/models.py +++ b/server/dive_utils/models.py @@ -37,6 +37,10 @@ class GeoJSONFeatureCollection(BaseModel): features: List[GeoJSONFeature] +# Type for a single additional point: {"coordinates": [x, y], "label": optional str} +AdditionalPointDict = Dict[str, Union[List[float], str]] + + class Feature(BaseModel): """Feature represents a single detection in a track.""" @@ -50,6 +54,8 @@ class Feature(BaseModel): fishLength: Optional[float] = None interpolate: Optional[bool] = None keyframe: Optional[bool] = None + # Point annotations keyed by type/label; multiple points per label per detection + additionalPoints: Optional[Dict[str, List[AdditionalPointDict]]] = None class BaseAnnotation(BaseModel): diff --git a/server/dive_utils/serializers/viame.py b/server/dive_utils/serializers/viame.py index f57930bce..4b6e87d03 100644 --- a/server/dive_utils/serializers/viame.py +++ b/server/dive_utils/serializers/viame.py @@ -426,6 +426,7 @@ def load_csv_as_tracks_and_attributes( fishLength=subFeature.fishLength or None, interpolate=subFeature.interpolate or None, keyframe=subFeature.keyframe or None, + additionalPoints=subFeature.additionalPoints or None, ) newFeature.frame = frameMapper[newFeature.frame] newTrack.features.append(newFeature)