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({
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ button.icon }}
+
+
+
+
+
+
+
+
+
- {{ 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)