From 523556b9a6e470cd3efc4a2545dfb15a1efc6da4 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 11 Aug 2026 17:02:09 -0400 Subject: [PATCH 1/3] Introduce notifier-free track projections --- client/src/CameraStore.spec.ts | 153 +++++++++++++++++++++++++++++ client/src/CameraStore.ts | 47 ++++++++- client/src/TrackProjection.spec.ts | 128 ++++++++++++++++++++++++ client/src/TrackProjection.ts | 118 ++++++++++++++++++++++ client/src/listUtils.ts | 4 +- client/src/track.ts | 40 ++++---- 6 files changed, 470 insertions(+), 20 deletions(-) create mode 100644 client/src/TrackProjection.spec.ts create mode 100644 client/src/TrackProjection.ts diff --git a/client/src/CameraStore.spec.ts b/client/src/CameraStore.spec.ts index d75083e2d..4bea94e69 100644 --- a/client/src/CameraStore.spec.ts +++ b/client/src/CameraStore.spec.ts @@ -148,3 +148,156 @@ describe('CameraStore classification commands', () => { expect(fixture.markChangesPending).not.toHaveBeenCalled(); }); }); + +describe('CameraStore track projections', () => { + const mutationKeys = [ + 'notifier', + 'revision', + 'setNotifier', + 'setFeature', + 'setFeatureNotes', + 'setAttribute', + 'merge', + 'toggleKeyframe', + ]; + + it('returns the same notifier-free read contract for a single camera', () => { + const markChangesPending = vi.fn(); + const store = new CameraStore({ markChangesPending }); + const track = new Track(TRACK_ID, { + confidencePairs: [['fish', 0.8]], + features: features(), + }); + store.camMap.value.get('singleCam')?.trackStore.insert(track, { imported: true }); + + const projection = store.getTrackProjection(TRACK_ID); + + expect(projection).not.toBe(track); + expect(projection.id).toBe(TRACK_ID); + expect(projection.getType()).toEqual(['fish', 0.8]); + mutationKeys.forEach((key) => expect(key in projection).toBe(false)); + expect(markChangesPending).not.toHaveBeenCalled(); + }); + + it('merges display data without mutating or notifying source tracks', () => { + const markChangesPending = vi.fn(); + const store = new CameraStore({ markChangesPending }); + store.removeCamera('singleCam'); + store.addCamera('left'); + store.addCamera('right'); + const leftFeatures: Feature[] = []; + leftFeatures[2] = { + frame: 2, keyframe: true, bounds: [0, 0, 2, 2], notes: ['left'], + }; + const rightFeatures: Feature[] = []; + rightFeatures[5] = { + frame: 5, keyframe: true, bounds: [5, 5, 2, 2], notes: ['right'], + }; + const left = new Track(TRACK_ID, { + attributes: { source: 'left' }, + begin: 2, + end: 2, + confidencePairs: [['fish', 0.7]], + features: leftFeatures, + }); + const right = new Track(TRACK_ID, { + attributes: { quality: 'right' }, + begin: 5, + end: 5, + confidencePairs: [['fish', 0.9], ['bird', 0.2]], + features: rightFeatures, + }); + store.camMap.value.get('left')?.trackStore.insert(left, { imported: true }); + store.camMap.value.get('right')?.trackStore.insert(right, { imported: true }); + markChangesPending.mockClear(); + const leftBefore = left.serialize(); + const rightBefore = right.serialize(); + + const projection = store.getTrackProjection(TRACK_ID); + + expect(projection.begin).toBe(2); + expect(projection.end).toBe(5); + expect(projection.featureIndex).toEqual([2, 5]); + expect(projection.features[2]?.notes).toEqual(['left']); + expect(projection.features[5]?.notes).toEqual(['right']); + expect(projection.attributes).toMatchObject({ source: 'left', quality: 'right' }); + expect(projection.confidencePairs).toEqual([['fish', 0.9], ['bird', 0.2]]); + mutationKeys.forEach((key) => expect(key in projection).toBe(false)); + expect(left.serialize()).toEqual(leftBefore); + expect(right.serialize()).toEqual(rightBefore); + expect(markChangesPending).not.toHaveBeenCalled(); + }); +}); + +describe('CameraStore projection cache', () => { + it('returns the same projection object until an input changes', () => { + const { store } = makeTwoCameraStore(); + const first = store.getTrackProjection(TRACK_ID); + expect(store.getTrackProjection(TRACK_ID)).toBe(first); + }); + + it('rebuilds after a canonical replica edit', () => { + const { store, left } = makeTwoCameraStore(); + const before = store.getTrackProjection(TRACK_ID); + left.setType('tuna'); + const after = store.getTrackProjection(TRACK_ID); + expect(after).not.toBe(before); + expect(after.confidencePairs).toContainEqual(['tuna', 1]); + }); + + it('rebuilds after an edit that touches only a non-canonical replica', () => { + const { store, right } = makeTwoCameraStore(); + const before = store.getTrackProjection(TRACK_ID); + right.setFeature({ frame: 5, keyframe: true, bounds: [1, 1, 2, 2] }); + const after = store.getTrackProjection(TRACK_ID); + expect(after).not.toBe(before); + expect(after.features[5]?.bounds).toEqual([1, 1, 2, 2]); + }); + + it('includes a replica inserted after the first read', () => { + const { store } = makeTwoCameraStore(); + store.addCamera('center'); + const before = store.getTrackProjection(TRACK_ID); + const centerFeatures: Feature[] = []; + centerFeatures[3] = { frame: 3, keyframe: true, bounds: [3, 3, 1, 1] }; + const center = new Track(TRACK_ID, { + begin: 3, + end: 3, + confidencePairs: confidencePairs([['crab', 0.5]]), + features: centerFeatures, + }); + store.camMap.value.get('center')?.trackStore.insert(center, { imported: true }); + const after = store.getTrackProjection(TRACK_ID); + expect(after).not.toBe(before); + expect(after.features[3]?.bounds).toEqual([3, 3, 1, 1]); + }); + + it('serves a replacement track after removal', () => { + const { store } = makeTwoCameraStore(); + store.getTrackProjection(TRACK_ID); + store.remove(TRACK_ID); + expect(() => store.getTrackProjection(TRACK_ID)).toThrow(); + const replacement = new Track(TRACK_ID, { + confidencePairs: confidencePairs([['crab', 1]]), + features: features(), + }); + store.camMap.value.get('left')?.trackStore.insert(replacement, { imported: true }); + expect(store.getTrackProjection(TRACK_ID).confidencePairs).toEqual([['crab', 1]]); + }); + + it('drops all entries on clearAll', () => { + const { store } = makeTwoCameraStore(); + store.getTrackProjection(TRACK_ID); + store.clearAll(); + expect(() => store.getTrackProjection(TRACK_ID)).toThrow(); + }); + + it('re-projects from the remaining camera after a camera is removed', () => { + const { store } = makeTwoCameraStore(); + const before = store.getTrackProjection(TRACK_ID); + store.removeCamera('left'); + const after = store.getTrackProjection(TRACK_ID); + expect(after).not.toBe(before); + expect(after.confidencePairs).toEqual(confidencePairs([['rock', 0.95], ['shark', 0.1]])); + }); +}); diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts index 9b7b12ba6..bc72e4957 100644 --- a/client/src/CameraStore.ts +++ b/client/src/CameraStore.ts @@ -1,5 +1,5 @@ import { - Ref, computed, shallowRef, triggerRef, + ComputedRef, Ref, computed, shallowRef, triggerRef, } from 'vue'; import { cloneDeep, uniq } from 'lodash'; import { @@ -16,6 +16,7 @@ import { AnnotationId, ConfidencePair } from './BaseAnnotation'; import { MarkChangesPending, SortedAnnotation } from './BaseAnnotationStore'; import GroupStore from './GroupStore'; import TrackStore from './TrackStore'; +import { createTrackProjection, TrackProjection } from './TrackProjection'; const FLAT_HIERARCHY_INDEX = compileHierarchy({}); @@ -42,10 +43,13 @@ export default class CameraStore { defaultGroup: [string, number]; + private projectionCache: Map>; + constructor({ markChangesPending }: { markChangesPending: MarkChangesPending }) { this.markChangesPending = markChangesPending; const cameraName = 'singleCam'; this.defaultGroup = ['no-group', 1.0]; + this.projectionCache = new Map(); this.camMap = shallowRef(new Map([[cameraName, { trackStore: new TrackStore({ markChangesPending, cameraName }), groupStore: new GroupStore({ markChangesPending, cameraName }), @@ -155,6 +159,45 @@ export default class CameraStore { return track; } + /** + * Each entry rebuilds when a replica in any camera changes, when replicas are + * inserted or removed, or when the camera set or order changes. Between edits, + * callers receive the same projection object, so it is a stable identity. + */ + private cachedProjection(trackId: Readonly): ComputedRef { + const cached = this.projectionCache.get(trackId); + if (cached !== undefined) { + return cached; + } + const entry = computed(() => { + const replicas: Track[] = []; + this.camMap.value.forEach(({ trackStore }) => { + if (trackStore.annotationIds.value.includes(trackId)) { + const track = trackStore.getPossible(trackId); + if (track) { + replicas.push(track); + } + } + }); + if (replicas.length === 0) { + return null; + } + // An edit to any replica, not only the canonical one, invalidates this entry. + replicas.forEach((track) => track.revision.value); + return createTrackProjection(replicas); + }); + this.projectionCache.set(trackId, entry); + return entry; + } + + getTrackProjection(trackId: Readonly): TrackProjection { + const projection = this.cachedProjection(trackId).value; + if (projection === null) { + throw Error(`TrackId: ${trackId} is not found in any camera`); + } + return projection; + } + getTracksMergedForSorted(trackId: Readonly): SortedAnnotation { const track = this.getTracksMerged(trackId); return { @@ -209,6 +252,7 @@ export default class CameraStore { } } }); + this.projectionCache.delete(trackId); } getNewTrackId() { @@ -227,6 +271,7 @@ export default class CameraStore { camera.trackStore.clearAll(); camera.groupStore.clearAll(); }); + this.projectionCache.clear(); } removeTracks(id: AnnotationId, cameraName = '') { diff --git a/client/src/TrackProjection.spec.ts b/client/src/TrackProjection.spec.ts new file mode 100644 index 000000000..2db966f34 --- /dev/null +++ b/client/src/TrackProjection.spec.ts @@ -0,0 +1,128 @@ +/// +import { createTrackProjection, TrackProjection } from './TrackProjection'; +import Track, { Feature } from './track'; + +/** + * The read contract a single-camera projection must answer identically to its source Track. + * Adding a member to TrackProjection without extending this list fails the closure test below. + */ +const READ_METHODS = [ + 'getType', + 'getFeature', + 'canSplit', + 'canInterpolate', + 'getNextKeyframe', + 'getPreviousKeyframe', +] as const; + +const READ_PROPERTIES = [ + 'id', + 'trackId', + 'meta', + 'attributes', + 'confidencePairs', + 'begin', + 'end', + 'length', + 'features', + 'featureIndex', + 'set', +] as const; + +function keyframe(frame: number, interpolate: boolean): Feature { + return { + frame, bounds: [frame, 0, frame + 10, 10], keyframe: true, interpolate, + }; +} + +function trackFrom(frames: number[], interpolate: boolean) { + const features: Feature[] = []; + frames.forEach((frame) => { features[frame] = keyframe(frame, interpolate); }); + return new Track(7, { + begin: frames[0], + end: frames[frames.length - 1], + confidencePairs: [['fish', 0.9], ['shark', 0.4]], + attributes: { source: 'fixture' }, + features, + }); +} + +const FIXTURES: [string, () => Track][] = [ + ['contiguous keyframes', () => trackFrom([0, 1, 2], false)], + ['gap with interpolation', () => trackFrom([0, 4], true)], + ['gap without interpolation', () => trackFrom([0, 4], false)], + ['single detection', () => trackFrom([0], false)], + ['keyframe removed mid-track', () => { + const track = trackFrom([0, 1, 2], false); + track.toggleKeyframe(1); + return track; + }], +]; + +/** Frames spanning before, inside, on, and past the track bounds. */ +const FRAMES = [-1, 0, 1, 2, 3, 4, 5]; + +describe('TrackProjection parity with its source Track', () => { + it.each(FIXTURES)('answers every read method identically for %s', (_name, makeTrack) => { + const track = makeTrack(); + const projection = createTrackProjection([track]); + + READ_PROPERTIES.forEach((property) => { + expect({ [property]: projection[property] }).toEqual({ [property]: track[property] }); + }); + + FRAMES.forEach((frame) => { + expect({ frame, getFeature: projection.getFeature(frame) }) + .toEqual({ frame, getFeature: track.getFeature(frame) }); + expect({ frame, canSplit: projection.canSplit(frame) }) + .toEqual({ frame, canSplit: track.canSplit(frame) }); + expect({ frame, canInterpolate: projection.canInterpolate(frame) }) + .toEqual({ frame, canInterpolate: track.canInterpolate(frame) }); + expect({ frame, next: projection.getNextKeyframe(frame) }) + .toEqual({ frame, next: track.getNextKeyframe(frame) }); + expect({ frame, previous: projection.getPreviousKeyframe(frame) }) + .toEqual({ frame, previous: track.getPreviousKeyframe(frame) }); + }); + + track.confidencePairs.forEach((_pair, index) => { + expect(projection.getType(index)).toEqual(track.getType(index)); + }); + }); + + it('covers every member of the projection read contract', () => { + const projection = createTrackProjection([trackFrom([0, 1], false)]); + const members = Object.keys(projection) as (keyof TrackProjection)[]; + const methods = members.filter((key) => typeof projection[key] === 'function'); + const properties = members.filter((key) => typeof projection[key] !== 'function'); + + expect(methods.sort()).toEqual([...READ_METHODS].sort()); + expect(properties.sort()).toEqual([...READ_PROPERTIES].sort()); + }); + + it('exposes no mutator or notifier from the source track', () => { + const projection = createTrackProjection([trackFrom([0, 1], false)]); + [ + 'setFeature', 'deleteFeature', 'toggleKeyframe', 'toggleInterpolation', 'split', + 'merge', 'setType', 'setAttribute', 'setNotifier', 'notify', 'revision', + ].forEach((key) => expect(key in projection).toBe(false)); + }); + + it('shares feature lookup with Track while retaining projection-owned copies', () => { + const track = trackFrom([0, 4], true); + const projection = createTrackProjection([track]); + + FRAMES.forEach((frame) => { + expect(Track.getFeatureFrom( + track.features, + track.featureIndex, + track.begin, + track.end, + frame, + )).toEqual(track.getFeature(frame)); + }); + + const [feature] = projection.getFeature(0); + (feature as Feature).bounds![0] = 99; + expect(track.getFeature(0)[0]?.bounds?.[0]).toBe(0); + }); +}); diff --git a/client/src/TrackProjection.ts b/client/src/TrackProjection.ts new file mode 100644 index 000000000..d78306225 --- /dev/null +++ b/client/src/TrackProjection.ts @@ -0,0 +1,118 @@ +import { cloneDeep } from 'lodash'; + +import type { Feature, InterpolateFeatures } from './track'; +import Track from './track'; +import type { + AnnotationId, ConfidencePair, StringKeyObject, +} from './BaseAnnotation'; + +export interface TrackProjection { + readonly id: AnnotationId; + readonly trackId: AnnotationId; + readonly meta?: Readonly; + readonly attributes: Readonly; + readonly confidencePairs: readonly Readonly[]; + readonly begin: number; + readonly end: number; + readonly length: number; + readonly features: readonly (Readonly | undefined)[]; + readonly featureIndex: readonly number[]; + readonly set?: string; + getType(index?: number): Readonly; + getFeature(frame: number): readonly [Feature | null, Feature | null, Feature | null]; + canInterpolate(frame: number): { + features: InterpolateFeatures; + interpolate: boolean; + }; + getNextKeyframe(frame: number): number | undefined; + getPreviousKeyframe(frame: number): number | undefined; +} + +function clonedFeatureResult( + result: readonly [Feature | null, Feature | null, Feature | null], +): [Feature | null, Feature | null, Feature | null] { + const [real, lower, upper] = result; + if (real !== null && real === lower && real === upper) { + const copy = cloneDeep(real) as Feature; + return [copy, copy, copy]; + } + const copies = result.map((feature) => (feature === null ? null : cloneDeep(feature) as Feature)); + return copies as [Feature | null, Feature | null, Feature | null]; +} + +export function createTrackProjection(tracks: readonly Track[]): TrackProjection { + const first = tracks[0]; + if (!first) { + throw new Error('Cannot project an empty logical track'); + } + + const features: (Feature | undefined)[] = []; + const confidenceByType = new Map(); + const attributes = cloneDeep(first.attributes); + tracks.forEach((track) => { + track.confidencePairs.forEach(([type, confidence]) => { + const current = confidenceByType.get(type); + if (current === undefined || confidence > current) { + confidenceByType.set(type, confidence); + } + }); + track.features.forEach((feature) => { + if (features[feature.frame] === undefined) { + features[feature.frame] = cloneDeep(feature); + } + }); + Object.entries(track.attributes).forEach(([key, value]) => { + if (attributes[key] === null || attributes[key] === undefined) { + attributes[key] = cloneDeep(value); + } + }); + }); + const featureIndex = features + .flatMap((feature) => (feature?.keyframe && feature.bounds ? [feature.frame] : [])); + const begin = Math.min(...tracks.map((track) => track.begin)); + const end = Math.max(...tracks.map((track) => track.end)); + const confidencePairs = Array.from(confidenceByType.entries()) as ConfidencePair[]; + + const projection: TrackProjection = { + id: first.id, + trackId: first.id, + meta: cloneDeep(first.meta), + attributes, + confidencePairs, + begin, + end, + length: (end - begin) + 1, + features, + featureIndex, + set: first.set, + getType(index = 0) { + const pair = confidencePairs[index]; + if (!pair) { + throw new Error('Index Error: The requested confidencePairs index does not exist.'); + } + return pair; + }, + getFeature(frame) { + return clonedFeatureResult(Track.getFeatureFrom(features, featureIndex, begin, end, frame)); + }, + canInterpolate(frame) { + const result = clonedFeatureResult( + Track.getFeatureFrom(features, featureIndex, begin, end, frame), + ); + const [real, lower, upper] = result; + return { + features: result, + interpolate: real?.interpolate + || lower?.interpolate + || (!lower && (upper?.interpolate || false)), + }; + }, + getNextKeyframe(frame) { + return features.slice(frame).find((feature) => feature)?.frame; + }, + getPreviousKeyframe(frame) { + return features.slice(0, frame + 1).reverse().find((feature) => feature)?.frame; + }, + }; + return projection; +} diff --git a/client/src/listUtils.ts b/client/src/listUtils.ts index e68ca2cf9..43bece00e 100644 --- a/client/src/listUtils.ts +++ b/client/src/listUtils.ts @@ -15,7 +15,7 @@ * a positive number of a is greater than b. */ function binarySearch( - arr: number[], + arr: readonly number[], el: number, ) { let m = 0; @@ -74,7 +74,7 @@ function listRemove( * such that return[0] <= position and return[1] > position */ function getSurroundingElements( - arr: number[], + arr: readonly number[], position: number, ): [number, number] | null { let starti = position; diff --git a/client/src/track.ts b/client/src/track.ts index 13f237d9b..08b1460a6 100644 --- a/client/src/track.ts +++ b/client/src/track.ts @@ -538,35 +538,41 @@ export default class Track extends BaseAnnotation { * [exact_feature_match, previous_keyframe, next_keyframe] */ getFeature(frame: number): [Feature | null, Feature | null, Feature | null] { - // First, try a direct keyframe hit - const maybeFrame = this.features[frame]; + return Track.getFeatureFrom(this.features, this.featureIndex, this.begin, this.end, frame); + } + + static getFeatureFrom( + features: readonly (Feature | undefined)[], + featureIndex: readonly number[], + begin: number, + end: number, + frame: number, + ): [Feature | null, Feature | null, Feature | null] { + const maybeFrame = features[frame]; if (maybeFrame) { return [maybeFrame, maybeFrame, maybeFrame]; } - // Then see if we are outside the track bounds - if (frame < this.begin || frame > this.end) { - if (frame <= this.begin) { - return [null, this.features[this.begin], null]; + if (frame < begin || frame > end) { + if (frame <= begin) { + return [null, features[begin] as Feature, null]; } - return [null, null, this.features[this.end]]; + return [null, null, features[end] as Feature]; } - // Then try to interpolate - const position = binarySearch(this.featureIndex, frame); - const maybeInterpolated = getSurroundingElements(this.featureIndex, position); + const position = binarySearch(featureIndex, frame); + const maybeInterpolated = getSurroundingElements(featureIndex, position); if (maybeInterpolated !== null) { - const [d0, d1] = maybeInterpolated.map((_frame) => this.features[_frame]); - return [Track.interpolate(frame, d0, d1), d0, d1]; + const [d0, d1] = maybeInterpolated.map((_frame) => features[_frame]); + return [Track.interpolate(frame, d0 as Feature, d1 as Feature), d0 as Feature, d1 as Feature]; } - if (this.featureIndex.length !== 0) { + if (featureIndex.length !== 0) { throw new Error(`Unexpected condition: Track bounds mis-aligned with feature array. - begin=${this.begin} - end=${this.end} - firstFeature=${this.featureIndex[0]} + begin=${begin} + end=${end} + firstFeature=${featureIndex[0]} `); } - // Should only reach here when there are no features (empty) return [null, null, null]; } From 46d42df508125bb5aa7edae052a3f92b14428280 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 11 Aug 2026 17:05:41 -0400 Subject: [PATCH 2/3] Use projections for merged track reads --- .../components/TrackDetailsPanel.spec.ts | 2 ++ .../components/TrackDetailsPanel.vue | 9 +++++-- client/dive-common/components/Viewer.vue | 8 +++--- client/dive-common/use/useModeManager.ts | 4 +-- client/src/CameraStore.ts | 2 +- client/src/TrackProjection.ts | 27 ++++++++++++------- .../src/components/Tracks/TrackList.spec.ts | 2 ++ client/src/components/Tracks/TrackList.vue | 25 +++++++++++------ client/src/use/useEventChart.spec.ts | 2 +- client/src/use/useEventChart.ts | 13 +++++---- client/src/use/useLineChart.spec.ts | 2 +- client/src/use/useLineChart.ts | 12 +++++---- 12 files changed, 67 insertions(+), 41 deletions(-) diff --git a/client/dive-common/components/TrackDetailsPanel.spec.ts b/client/dive-common/components/TrackDetailsPanel.spec.ts index f5880a572..cad790370 100644 --- a/client/dive-common/components/TrackDetailsPanel.spec.ts +++ b/client/dive-common/components/TrackDetailsPanel.spec.ts @@ -3,6 +3,7 @@ import { defineComponent, h, ref } from 'vue'; import { shallowMount } from '@vue/test-utils'; import Track from 'vue-media-annotator/track'; +import { createTrackProjection } from 'vue-media-annotator/TrackProjection'; import TrackDetailsPanel from './TrackDetailsPanel.vue'; const state = vi.hoisted(() => ({ @@ -45,6 +46,7 @@ vi.mock('vue-media-annotator/provides', () => ({ camMap: ref(new Map([['singleCam', { groupStore: undefined }]])), getAnyTrack: () => state.track, getAnyPossibleTrack: () => state.track, + getTrackProjection: () => createTrackProjection([state.track as Track]), acceptTrackType: state.acceptTrackType, assignTrackType: state.assignTrackType, }), diff --git a/client/dive-common/components/TrackDetailsPanel.vue b/client/dive-common/components/TrackDetailsPanel.vue index 40bfa1a41..a16560c61 100644 --- a/client/dive-common/components/TrackDetailsPanel.vue +++ b/client/dive-common/components/TrackDetailsPanel.vue @@ -272,6 +272,9 @@ export default defineComponent({ track, // Re-run when track confidence pairs change (see AttributesSubsection revision pattern) revision: track.revision.value, + // TrackItem reads a TrackProjection, whose identity changes on every recompute; a live + // Track keeps one identity, so the child's computeds would never see the mutation. + projection: cameraStore.getTrackProjection(track.id), pairIndex, pair: track.confidencePairs.length ? track.confidencePairs[pairIndex] : null, }; @@ -442,7 +445,9 @@ export default defineComponent({ class="track-details" > cameraStore.removeTypes(id, types); - const getTracksMerged = (id: AnnotationId) => cameraStore.getTracksMerged(id); + const getTrackProjection = (id: AnnotationId) => cameraStore.getTrackProjection(id); const groupFilters = new GroupFilterControls({ sorted: cameraStore.sortedGroups, markChangesPending: (markChangesPending as MarkChangesPendingFilter), @@ -784,14 +784,14 @@ export default defineComponent({ enabledTracks: trackFilters.enabledAnnotations, typeStyling: trackStyleManager.typeStyling, allTypes: trackFilters.allTypes, - getTracksMerged, + getTrackProjection, }); const { eventChartData } = useEventChart({ enabledTracks: trackFilters.enabledAnnotations, selectedTrackIds: allSelectedIds, typeStyling: trackStyleManager.typeStyling, - getTracksMerged, + getTrackProjection, }); const { eventChartData: groupChartData } = useEventChart({ @@ -803,7 +803,7 @@ export default defineComponent({ } return []; }), - getTracksMerged, + getTrackProjection, }); async function trackSplit(trackId: AnnotationId | null, frame: number) { diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 71707ec5c..69b9d0f60 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -324,7 +324,7 @@ export default function useModeManager({ : interpolateTrack; } - function seekNearest(track: Track) { + function seekNearest(track: Readonly>) { // Seek to the nearest point in the track. Compares/seeks using // selectedCamera's own local frame (see selectedCameraFrame) rather than // aggregateController.frame directly -- under an aligned timeline (SEAL @@ -1203,7 +1203,7 @@ export default function useModeManager({ } function handleTrackClick(trackId: TrackId, modifiers?: { ctrl: boolean }) { - const track = cameraStore.getTracksMerged(trackId); + const track = cameraStore.getTrackProjection(trackId); seekNearest(track); handleSelectTrack(trackId, editingTrack.value, modifiers); } diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts index bc72e4957..3edd0cef1 100644 --- a/client/src/CameraStore.ts +++ b/client/src/CameraStore.ts @@ -199,7 +199,7 @@ export default class CameraStore { } getTracksMergedForSorted(trackId: Readonly): SortedAnnotation { - const track = this.getTracksMerged(trackId); + const track = this.getTrackProjection(trackId); return { id: track.id, confidencePairs: track.confidencePairs, diff --git a/client/src/TrackProjection.ts b/client/src/TrackProjection.ts index d78306225..85287ab61 100644 --- a/client/src/TrackProjection.ts +++ b/client/src/TrackProjection.ts @@ -47,15 +47,24 @@ export function createTrackProjection(tracks: readonly Track[]): TrackProjection } const features: (Feature | undefined)[] = []; - const confidenceByType = new Map(); + let confidencePairs = first.confidencePairs + .map(([type, confidence]) => [type, confidence] as ConfidencePair); const attributes = cloneDeep(first.attributes); - tracks.forEach((track) => { - track.confidencePairs.forEach(([type, confidence]) => { - const current = confidenceByType.get(type); - if (current === undefined || confidence > current) { - confidenceByType.set(type, confidence); - } - }); + tracks.forEach((track, trackIndex) => { + if (trackIndex > 0) { + track.confidencePairs.forEach(([type, confidence]) => { + const current = confidencePairs.find(([name]) => name === type); + if (current === undefined || confidence > current[1]) { + if (confidence >= 1) { + confidencePairs = [[type, 1]]; + } else { + confidencePairs = confidencePairs.filter(([name]) => name !== type); + confidencePairs.push([type, confidence]); + confidencePairs.sort((a, b) => b[1] - a[1]); + } + } + }); + } track.features.forEach((feature) => { if (features[feature.frame] === undefined) { features[feature.frame] = cloneDeep(feature); @@ -71,8 +80,6 @@ export function createTrackProjection(tracks: readonly Track[]): TrackProjection .flatMap((feature) => (feature?.keyframe && feature.bounds ? [feature.frame] : [])); const begin = Math.min(...tracks.map((track) => track.begin)); const end = Math.max(...tracks.map((track) => track.end)); - const confidencePairs = Array.from(confidenceByType.entries()) as ConfidencePair[]; - const projection: TrackProjection = { id: first.id, trackId: first.id, diff --git a/client/src/components/Tracks/TrackList.spec.ts b/client/src/components/Tracks/TrackList.spec.ts index 663826760..e537c4c15 100644 --- a/client/src/components/Tracks/TrackList.spec.ts +++ b/client/src/components/Tracks/TrackList.spec.ts @@ -10,6 +10,7 @@ import TrackList from './TrackList.vue'; interface MockCameraStore { camMap: Ref>; getTracksMerged: (id: number) => Track | undefined; + getTrackProjection: (id: number) => Track | undefined; getAnyPossibleTrack: (id: number) => Track | undefined; } @@ -87,6 +88,7 @@ function mountList( state.cameraStore = { camMap: ref(new Map([['singleCam', { trackStore: undefined }]])), getTracksMerged: (id: number) => byId.get(id), + getTrackProjection: (id: number) => byId.get(id), getAnyPossibleTrack: (id: number) => byId.get(id), }; // `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index 40d8a4a00..98aa3696f 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -7,6 +7,7 @@ import Vue, { import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; import { TrackWithContext } from 'vue-media-annotator/BaseFilterControls'; +import type { TrackProjection } from 'vue-media-annotator/TrackProjection'; import { clientSettings } from 'dive-common/store/settings'; import { @@ -105,7 +106,7 @@ export default defineComponent({ const sortDirection = ref('asc'); const displayConfidence = ( - track: ReturnType, + track: TrackProjection, contextIndex: number, ) => { const pairIndex = trackFilters.hierarchyActive.value ? contextIndex : 0; @@ -158,7 +159,7 @@ export default defineComponent({ } // Helper to get notes from a track's first keyframe - function getTrackNotes(track: ReturnType): string { + function getTrackNotes(track: TrackProjection): string { // Try direct access first (most common case) const directFeature = track.features[track.begin]; if (directFeature && directFeature.notes && directFeature.notes.length > 0) { @@ -175,7 +176,7 @@ export default defineComponent({ // Helper to get attribute value from a track function getTrackAttributeValue( - track: ReturnType, + track: TrackProjection, attrKey: string, ): string | number | undefined { // Check if it's a track attribute (track_*) or detection attribute (detection_*) @@ -201,13 +202,21 @@ export default defineComponent({ const sorted = [...tracks]; const direction = sortDirection.value === 'asc' ? 1 : -1; - sorted.sort((a, b) => { - let trackA; - let trackB; + // Projections copy the whole feature history, so build one per track rather than + // one per comparison: the comparator runs O(n log n) times over the same n tracks. + const projections = new Map(); + tracks.forEach(({ annotation }) => { try { - trackA = cameraStore.getTracksMerged(a.annotation.id); - trackB = cameraStore.getTracksMerged(b.annotation.id); + projections.set(annotation.id, cameraStore.getTrackProjection(annotation.id)); } catch { + // Track vanished between filtering and sorting; comparisons involving it are ties. + } + }); + + sorted.sort((a, b) => { + const trackA = projections.get(a.annotation.id); + const trackB = projections.get(b.annotation.id); + if (!trackA || !trackB) { return 0; } diff --git a/client/src/use/useEventChart.spec.ts b/client/src/use/useEventChart.spec.ts index b4bca38c3..e2a9f211e 100644 --- a/client/src/use/useEventChart.spec.ts +++ b/client/src/use/useEventChart.spec.ts @@ -55,7 +55,7 @@ describe('useEventChart display context', () => { enabledTracks, selectedTrackIds: ref([]), typeStyling, - getTracksMerged: vi.fn(), + getTrackProjection: vi.fn(), }); expect(eventChartData.value.values).toEqual([expect.objectContaining({ diff --git a/client/src/use/useEventChart.ts b/client/src/use/useEventChart.ts index 954ddd9e2..8e9240dba 100644 --- a/client/src/use/useEventChart.ts +++ b/client/src/use/useEventChart.ts @@ -3,13 +3,14 @@ import type { AnnotationWithContext } from '../BaseFilterControls'; import type { TypeStyling } from '../StyleManager'; import BaseAnnotation, { AnnotationId } from '../BaseAnnotation'; import type Track from '../track'; +import type { TrackProjection } from '../TrackProjection'; import { Group } from '..'; interface EventChartParams { enabledTracks: Readonly>[]>>; selectedTrackIds: Ref; typeStyling: Ref; - getTracksMerged: (id: AnnotationId) => Track; + getTrackProjection: (id: AnnotationId) => TrackProjection; } export interface EventChartData { @@ -23,7 +24,7 @@ export interface EventChartData { } export default function useEventChart({ - enabledTracks, selectedTrackIds, typeStyling, getTracksMerged, + enabledTracks, selectedTrackIds, typeStyling, getTrackProjection, }: EventChartParams) { const eventChartData = computed(() => { const values = [] as EventChartData[]; @@ -35,11 +36,9 @@ export default function useEventChart({ const { confidencePairs } = track; let markers: [number, boolean][] = []; if (selectedTrackIds.value.includes(filtered.annotation.id)) { - const mergedTrack = getTracksMerged(filtered.annotation.id); - if ('featureIndex' in mergedTrack) { - markers = mergedTrack.featureIndex.map((i) => ( - [i, mergedTrack.features[i].interpolate || false])); - } + const projection = getTrackProjection(filtered.annotation.id); + markers = projection.featureIndex.map((i) => ( + [i, projection.features[i]?.interpolate || false])); } if (confidencePairs.length) { const trackType = track.getType(filtered.context.confidencePairIndex); diff --git a/client/src/use/useLineChart.spec.ts b/client/src/use/useLineChart.spec.ts index 173c570e4..c7a0b3149 100644 --- a/client/src/use/useLineChart.spec.ts +++ b/client/src/use/useLineChart.spec.ts @@ -61,7 +61,7 @@ describe('useLineChart display context', () => { enabledTracks, allTypes: ref(['root', 'leaf']), typeStyling, - getTracksMerged: vi.fn(), + getTrackProjection: vi.fn(), }); const selectedSeries = lineChartData.value.find(({ name }) => name === expected); diff --git a/client/src/use/useLineChart.ts b/client/src/use/useLineChart.ts index 416e46785..eb539225e 100644 --- a/client/src/use/useLineChart.ts +++ b/client/src/use/useLineChart.ts @@ -1,7 +1,7 @@ import { computed, Ref } from 'vue'; import { clientSettings } from 'dive-common/store/settings'; import { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; -import Track from 'vue-media-annotator/track'; +import type { TrackProjection } from 'vue-media-annotator/TrackProjection'; import type { TrackWithContext } from '../BaseFilterControls'; import type { TypeStyling } from '../StyleManager'; @@ -9,7 +9,7 @@ interface UseLineChartParams { enabledTracks: Readonly>; typeStyling: Ref; allTypes: Readonly>; - getTracksMerged: (id: AnnotationId) => Track; + getTrackProjection: (id: AnnotationId) => TrackProjection; } @@ -55,7 +55,7 @@ export default function useLineChart({ enabledTracks, typeStyling, allTypes, - getTracksMerged, + getTrackProjection, }: UseLineChartParams) { const lineChartData = computed(() => { /* Histogram map contains multiple histograms keyed @@ -74,8 +74,10 @@ export default function useLineChart({ enabledTracks.value.forEach((filtered) => { const { annotation: track } = filtered; if (clientSettings.timelineCountSettings.defaultView === 'detections') { - const trackObj = getTracksMerged(track.id); - const frames = trackObj.features.filter((item) => item && item.keyframe).map((item) => item.frame); + const trackObj = getTrackProjection(track.id); + const frames = trackObj.features + .filter((item) => item?.keyframe) + .map((item) => item?.frame as number); const segments = framesToSegments(frames); segments.forEach((segment) => { const ibegin = segment[0]; From 9bc865e1dbd24e25f6c11ec1d96cf9017981aa8b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 11 Aug 2026 17:12:05 -0400 Subject: [PATCH 3/3] Route track editors through canonical commands --- .../Attributes/AttributesSubsection.vue | 26 +++-- client/src/BaseAnnotation.ts | 6 +- client/src/CameraStore.spec.ts | 66 +++++++++++ client/src/CameraStore.ts | 109 +++++++++++++----- client/src/TrackProjection.ts | 4 + client/src/components/Tracks/TrackItem.vue | 58 +++++----- .../src/components/Tracks/TrackList.spec.ts | 2 - client/src/components/Tracks/TrackList.vue | 2 +- .../bottombar/BottomBarTrackItemView.spec.ts | 35 +++++- .../bottombar/BottomBarTrackItemView.vue | 27 +++-- .../sidebar/SideBarTrackItemView.spec.ts | 40 ++++++- .../Tracks/sidebar/SideBarTrackItemView.vue | 10 +- client/src/track.ts | 8 +- docs/UI-Type-List.md | 2 + 14 files changed, 288 insertions(+), 107 deletions(-) diff --git a/client/dive-common/components/Attributes/AttributesSubsection.vue b/client/dive-common/components/Attributes/AttributesSubsection.vue index 97bcc451b..fa6f80fbb 100644 --- a/client/dive-common/components/Attributes/AttributesSubsection.vue +++ b/client/dive-common/components/Attributes/AttributesSubsection.vue @@ -111,22 +111,24 @@ export default defineComponent({ attribute: Attribute, ) { if (selectedTrackIdRef.value !== null) { - // Tracks across all cameras get the same attributes set if they are linked - const tracks = cameraStore.getTrackAll(selectedTrackIdRef.value); let user: null | string = null; if (attribute && attribute.user) { user = props.user || null; } - if (tracks.length) { - let updatedValue = value; - if (attribute.datatype === 'number' && value !== undefined) { - updatedValue = parseFloat(value as string); - } - if (props.mode === 'Track') { - tracks.forEach((track) => track.setAttribute(name, updatedValue, user)); - } else if (props.mode === 'Detection' && frameRef.value !== undefined) { - tracks.forEach((track) => track.setFeatureAttribute(frameRef.value, name, updatedValue, user)); - } + let updatedValue = value; + if (attribute.datatype === 'number' && value !== undefined) { + updatedValue = parseFloat(value as string); + } + if (props.mode === 'Track') { + cameraStore.setTrackAttribute(selectedTrackIdRef.value, name, updatedValue, user); + } else if (props.mode === 'Detection' && frameRef.value !== undefined) { + cameraStore.setTrackFeatureAttribute( + selectedTrackIdRef.value, + frameRef.value, + name, + updatedValue, + user, + ); } } } diff --git a/client/src/BaseAnnotation.ts b/client/src/BaseAnnotation.ts index 0f925b570..92f0e4295 100644 --- a/client/src/BaseAnnotation.ts +++ b/client/src/BaseAnnotation.ts @@ -57,9 +57,6 @@ export default abstract class BaseAnnotation { /** A callback to notify about changes to the track. */ notifier?: NotifierFunc; - /** Enables/Disables the notifier specifically for multicam merge */ - notifierEnabled: boolean; - constructor(id: AnnotationId, { meta = {}, begin = Infinity, @@ -74,7 +71,6 @@ export default abstract class BaseAnnotation { this.begin = begin; this.end = end; this.confidencePairs = confidencePairs; - this.notifierEnabled = true; } get length() { @@ -106,7 +102,7 @@ export default abstract class BaseAnnotation { protected notify(name: string, oldValue: unknown = undefined) { /* Prevent broadcast until the first feature is initialized */ - if (this.isInitialized() && this.notifierEnabled) { + if (this.isInitialized()) { this.revision.value += 1; if (this.notifier) { this.notifier({ diff --git a/client/src/CameraStore.spec.ts b/client/src/CameraStore.spec.ts index 4bea94e69..827b2ecae 100644 --- a/client/src/CameraStore.spec.ts +++ b/client/src/CameraStore.spec.ts @@ -31,6 +31,8 @@ function makeTwoCameraStore() { store.addCamera('right'); const left = new Track(TRACK_ID, { + begin: 0, + end: 0, confidencePairs: confidencePairs([ ['fish', 0.9], ['shark', 0.7], @@ -40,6 +42,8 @@ function makeTwoCameraStore() { features: features(), }); const right = new Track(TRACK_ID, { + begin: 0, + end: 0, confidencePairs: confidencePairs([ ['rock', 0.95], ['shark', 0.1], @@ -179,6 +183,27 @@ describe('CameraStore track projections', () => { expect(markChangesPending).not.toHaveBeenCalled(); }); + it('answers canSplit over the merged logical range', () => { + const markChangesPending = vi.fn(); + const store = new CameraStore({ markChangesPending }); + const trackFeatures = features(); + trackFeatures[4] = { frame: 4, keyframe: true, bounds: [0, 0, 1, 1] }; + const track = new Track(TRACK_ID, { + begin: 0, + end: 4, + confidencePairs: [['fish', 0.8]], + features: trackFeatures, + }); + store.camMap.value.get('singleCam')?.trackStore.insert(track, { imported: true }); + + const projection = store.getTrackProjection(TRACK_ID); + + [0, 1, 4, 5].forEach((frame) => { + expect(projection.canSplit(frame)).toBe(track.canSplit(frame)); + }); + expect(markChangesPending).not.toHaveBeenCalled(); + }); + it('merges display data without mutating or notifying source tracks', () => { const markChangesPending = vi.fn(); const store = new CameraStore({ markChangesPending }); @@ -301,3 +326,44 @@ describe('CameraStore projection cache', () => { expect(after.confidencePairs).toEqual(confidencePairs([['rock', 0.95], ['shark', 0.1]])); }); }); + +describe('CameraStore track editor commands', () => { + it('writes notes and attributes to every replica through canonical tracks', () => { + const fixture = makeTwoCameraStore(); + + fixture.store.setTrackNotes(TRACK_ID, 'reviewed'); + fixture.store.setTrackAttribute(TRACK_ID, 'quality', 'high'); + fixture.store.setTrackFirstFeatureAttribute(TRACK_ID, 'occluded', true); + + [fixture.left, fixture.right].forEach((track) => { + expect(track.features[track.begin].notes).toEqual(['reviewed']); + expect(track.attributes.quality).toBe('high'); + expect(track.features[track.begin].attributes?.occluded).toBe(true); + }); + expect(fixture.markChangesPending).toHaveBeenCalledTimes(6); + expect(fixture.markChangesPending.mock.calls.map(([change]) => change.cameraName)) + .toEqual(['left', 'right', 'left', 'right', 'left', 'right']); + }); + + it('writes frame attributes to every replica at the declared frame', () => { + const fixture = makeTwoCameraStore(); + + fixture.store.setTrackFeatureAttribute(TRACK_ID, 0, 'reviewed', true); + + expect(fixture.left.features[0].attributes?.reviewed).toBe(true); + expect(fixture.right.features[0].attributes?.reviewed).toBe(true); + expect(fixture.markChangesPending.mock.calls.map(([change]) => change.cameraName)) + .toEqual(['left', 'right']); + }); + + it('targets geometry commands to one named camera', () => { + const fixture = makeTwoCameraStore(); + + fixture.store.toggleTrackInterpolation(TRACK_ID, 0, 'right'); + + expect(fixture.left.features[0].interpolate).toBeUndefined(); + expect(fixture.right.features[0].interpolate).toBe(true); + expect(fixture.markChangesPending.mock.calls.map(([change]) => change.cameraName)) + .toEqual(['right']); + }); +}); diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts index 3edd0cef1..1b2157a2d 100644 --- a/client/src/CameraStore.ts +++ b/client/src/CameraStore.ts @@ -1,7 +1,7 @@ import { ComputedRef, Ref, computed, shallowRef, triggerRef, } from 'vue'; -import { cloneDeep, uniq } from 'lodash'; +import { uniq } from 'lodash'; import { acceptPairAsCorrect, compileHierarchy, @@ -65,7 +65,7 @@ export default class CameraStore { * This allows the full range begin/end for the track across multiple cameras to * be displayed. */ - return uniq(idList).map((id) => this.getTracksMergedForSorted(id)); + return uniq(idList).map((id) => this.getTrackProjectionForSorted(id)); }); this.sortedGroups = computed(() => { let list: SortedAnnotation[] = []; @@ -136,29 +136,6 @@ export default class CameraStore { return trackList; } - getTracksMerged( - trackId: Readonly, - ): Track { - if (this.camMap.value.size === 1) { - return this.getTrack(trackId); - } - let track: Track | undefined; - this.camMap.value.forEach((camera) => { - const tempTrack = camera.trackStore.getPossible(trackId); - if (!track && tempTrack) { - track = cloneDeep(tempTrack); - } else if (track && tempTrack) { - // Merge track bounds and data together - // We don't care about feature data just that features are at X frame - track.merge([tempTrack], true); - } - }); - if (!track) { - throw Error(`TrackId: ${trackId} is not found in any camera`); - } - return track; - } - /** * Each entry rebuilds when a replica in any camera changes, when replicas are * inserted or removed, or when the camera set or order changes. Between edits, @@ -198,14 +175,18 @@ export default class CameraStore { return projection; } - getTracksMergedForSorted(trackId: Readonly): SortedAnnotation { + getTrackProjectionForSorted(trackId: Readonly): SortedAnnotation { const track = this.getTrackProjection(trackId); + // A projection's vector is read-only; sorted annotations declare a mutable one, so the + // caller gets a copy rather than a window onto stored evidence. + const confidencePairs = track.confidencePairs + .map(([type, confidence]) => [type, confidence] as ConfidencePair); return { id: track.id, - confidencePairs: track.confidencePairs, + confidencePairs, begin: track.begin, end: track.end, - getType: (index?: number) => (track.confidencePairs[index || 0][0] || 'unknown'), + getType: (index?: number) => (confidencePairs[index || 0]?.[0] || 'unknown'), }; } @@ -377,6 +358,78 @@ export default class CameraStore { }); } + setTrackNotes(id: AnnotationId, notes: string): void { + const tracks = this.getTrackAll(id); + if (tracks.length === 0) { + throw new Error(`TrackId ${id} not found in any camera`); + } + tracks.forEach((track) => track.setFeatureNotes(track.begin, notes)); + } + + setTrackAttribute( + id: AnnotationId, + key: string, + value: unknown, + user: null | string = null, + ): void { + const tracks = this.getTrackAll(id); + if (tracks.length === 0) { + throw new Error(`TrackId ${id} not found in any camera`); + } + tracks.forEach((track) => track.setAttribute(key, value, user)); + } + + setTrackFeatureAttribute( + id: AnnotationId, + frame: number, + key: string, + value: unknown, + user: null | string = null, + ): void { + const tracks = this.getTrackAll(id); + if (tracks.length === 0) { + throw new Error(`TrackId ${id} not found in any camera`); + } + tracks.forEach((track) => track.setFeatureAttribute(frame, key, value, user)); + } + + setTrackFirstFeatureAttribute( + id: AnnotationId, + key: string, + value: unknown, + user: null | string = null, + ): void { + const tracks = this.getTrackAll(id); + if (tracks.length === 0) { + throw new Error(`TrackId ${id} not found in any camera`); + } + tracks.forEach((track) => track.setFeatureAttribute(track.begin, key, value, user)); + } + + /** + * Keyframe and interpolation edits are camera-local geometry, but the row that triggers them + * comes from the all-camera projection, so the selected camera need not hold a replica. + */ + private getTrackForCameraEdit(id: AnnotationId, cameraName: string): Track | undefined { + return this.getPossibleTrack(id, cameraName) ?? this.getAnyPossibleTrack(id); + } + + toggleTrackKeyframe(id: AnnotationId, frame: number, cameraName: string): void { + this.getTrackForCameraEdit(id, cameraName)?.toggleKeyframe(frame); + } + + toggleTrackInterpolation(id: AnnotationId, frame: number, cameraName: string): void { + this.getTrackForCameraEdit(id, cameraName)?.toggleInterpolation(frame); + } + + toggleTrackInterpolationForAllGaps( + id: AnnotationId, + frame: number, + cameraName: string, + ): void { + this.getTrackForCameraEdit(id, cameraName)?.toggleInterpolationForAllGaps(frame); + } + removeTypes(id: AnnotationId, types: string[]) { let resultingTypes: ConfidencePair[] = []; this.camMap.value.forEach((camera) => { diff --git a/client/src/TrackProjection.ts b/client/src/TrackProjection.ts index 85287ab61..ffe6299cb 100644 --- a/client/src/TrackProjection.ts +++ b/client/src/TrackProjection.ts @@ -20,6 +20,7 @@ export interface TrackProjection { readonly set?: string; getType(index?: number): Readonly; getFeature(frame: number): readonly [Feature | null, Feature | null, Feature | null]; + canSplit(frame: number): boolean; canInterpolate(frame: number): { features: InterpolateFeatures; interpolate: boolean; @@ -102,6 +103,9 @@ export function createTrackProjection(tracks: readonly Track[]): TrackProjection getFeature(frame) { return clonedFeatureResult(Track.getFeatureFrom(features, featureIndex, begin, end, frame)); }, + canSplit(frame) { + return frame > begin && frame <= end; + }, canInterpolate(frame) { const result = clonedFeatureResult( Track.getFeatureFrom(features, featureIndex, begin, end, frame), diff --git a/client/src/components/Tracks/TrackItem.vue b/client/src/components/Tracks/TrackItem.vue index 89b4c1258..5b4c9f9eb 100644 --- a/client/src/components/Tracks/TrackItem.vue +++ b/client/src/components/Tracks/TrackItem.vue @@ -5,8 +5,8 @@ import { import { ColumnVisibilitySettings } from 'dive-common/store/settings'; import SideBarTrackItemView from './sidebar/SideBarTrackItemView.vue'; import BottomBarTrackItemView from './bottombar/BottomBarTrackItemView.vue'; -import { useTime } from '../../provides'; -import Track from '../../track'; +import { useCameraStore, useSelectedCamera, useTime } from '../../provides'; +import type { TrackProjection } from '../../TrackProjection'; import useVuetify from '../../use/useVuetify'; export default defineComponent({ @@ -28,7 +28,7 @@ export default defineComponent({ required: true, }, track: { - type: Object as PropType, + type: Object as PropType, required: true, }, inputValue: { @@ -80,32 +80,24 @@ export default defineComponent({ setup(props, { emit }) { const vuetify = useVuetify(); const { frame: frameRef } = useTime(); + const cameraStore = useCameraStore(); + const selectedCamera = useSelectedCamera(); /** - * Use of revision is safe because it will only create a - * dependency when track is selected. DO NOT use this computed - * value except inside if (props.selected === true) blocks! + * Recomputes when the track prop is rebuilt, which TrackList does whenever the annotation + * store notifies. DO NOT use this computed value except inside if (props.selected === true) + * blocks! */ const feature = computed(() => { - if (props.track.revision.value) { - const { features, interpolate } = props.track.canInterpolate(frameRef.value); - const [real, lower, upper] = features; - return { - real, - lower, - upper, - shouldInterpolate: interpolate, - targetKeyframe: real?.keyframe ? real : (lower || upper), - isKeyframe: real?.keyframe, - }; - } + const { features, interpolate } = props.track.canInterpolate(frameRef.value); + const [real, lower, upper] = features; return { - real: null, - lower: null, - upper: null, - targetKeyframe: null, - shouldInterpolate: false, - isKeyframe: false, + real, + lower, + upper, + shouldInterpolate: interpolate, + targetKeyframe: real?.keyframe ? real : (lower || upper), + isKeyframe: real?.keyframe, }; }); @@ -133,16 +125,28 @@ export default defineComponent({ function toggleKeyframe() { if (!keyframeDisabled.value) { - props.track.toggleKeyframe(frameRef.value); + cameraStore.toggleTrackKeyframe( + props.track.id, + frameRef.value, + selectedCamera.value, + ); } } function toggleInterpolation() { - props.track.toggleInterpolation(frameRef.value); + cameraStore.toggleTrackInterpolation( + props.track.id, + frameRef.value, + selectedCamera.value, + ); } function toggleAllInterpolation() { - props.track.toggleInterpolationForAllGaps(frameRef.value); + cameraStore.toggleTrackInterpolationForAllGaps( + props.track.id, + frameRef.value, + selectedCamera.value, + ); } function clickToggleInterpolation(event: MouseEvent) { diff --git a/client/src/components/Tracks/TrackList.spec.ts b/client/src/components/Tracks/TrackList.spec.ts index e537c4c15..57eff86c3 100644 --- a/client/src/components/Tracks/TrackList.spec.ts +++ b/client/src/components/Tracks/TrackList.spec.ts @@ -9,7 +9,6 @@ import TrackList from './TrackList.vue'; interface MockCameraStore { camMap: Ref>; - getTracksMerged: (id: number) => Track | undefined; getTrackProjection: (id: number) => Track | undefined; getAnyPossibleTrack: (id: number) => Track | undefined; } @@ -87,7 +86,6 @@ function mountList( }; state.cameraStore = { camMap: ref(new Map([['singleCam', { trackStore: undefined }]])), - getTracksMerged: (id: number) => byId.get(id), getTrackProjection: (id: number) => byId.get(id), getAnyPossibleTrack: (id: number) => byId.get(id), }; diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index 98aa3696f..d723add0c 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -332,7 +332,7 @@ export default defineComponent({ ); const trackType = confidencePair; const selected = item.selectedTrackId === item.filteredTrack.annotation.id; - const track = cameraStore.getTracksMerged(item.filteredTrack.annotation.id); + const track = cameraStore.getTrackProjection(item.filteredTrack.annotation.id); return { trackType, track, diff --git a/client/src/components/Tracks/bottombar/BottomBarTrackItemView.spec.ts b/client/src/components/Tracks/bottombar/BottomBarTrackItemView.spec.ts index 724569627..e7acbc1b7 100644 --- a/client/src/components/Tracks/bottombar/BottomBarTrackItemView.spec.ts +++ b/client/src/components/Tracks/bottombar/BottomBarTrackItemView.spec.ts @@ -8,6 +8,9 @@ import BottomBarTrackItemView from './BottomBarTrackItemView.vue'; const providerState = vi.hoisted(() => ({ assignTrackType: vi.fn(), setTrackPairConfidence: vi.fn(), + setTrackNotes: vi.fn(), + setTrackAttribute: vi.fn(), + setTrackFirstFeatureAttribute: vi.fn(), })); vi.mock('../../../provides', () => ({ @@ -20,11 +23,16 @@ vi.mock('../../../provides', () => ({ useCameraStore: () => ({ assignTrackType: providerState.assignTrackType, setTrackPairConfidence: providerState.setTrackPairConfidence, + setTrackNotes: providerState.setTrackNotes, + setTrackAttribute: providerState.setTrackAttribute, + setTrackFirstFeatureAttribute: providerState.setTrackFirstFeatureAttribute, }), })); function mountItem(displayPairIndex: number) { const track = new Track(1, { + begin: 0, + end: 0, confidencePairs: [['root', 0.9], ['leaf', 0.7]], features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], }); @@ -63,8 +71,7 @@ function mountItem(displayPairIndex: number) { describe('BottomBarTrackItemView hierarchy display', () => { beforeEach(() => { - providerState.assignTrackType.mockClear(); - providerState.setTrackPairConfidence.mockClear(); + vi.clearAllMocks(); }); it('renders and seeds editing from the selected hierarchy pair', () => { @@ -101,4 +108,28 @@ describe('BottomBarTrackItemView hierarchy display', () => { expect(providerState.setTrackPairConfidence).toHaveBeenCalledWith(1, 'leaf', Number(value)); }); + + it('routes notes and both attribute scopes through logical-track commands', () => { + const wrapper = mountItem(1); + const vm = wrapper.vm as unknown as { + setEditNotesValue: (value: string) => void; + saveNotes: () => void; + editingAttributeKey: string | null; + setEditAttributeValue: (value: string) => void; + saveAttribute: () => void; + }; + vm.setEditNotesValue('reviewed'); + vm.saveNotes(); + vm.editingAttributeKey = 'track_quality'; + vm.setEditAttributeValue('high'); + vm.saveAttribute(); + vm.editingAttributeKey = 'detection_occluded'; + vm.setEditAttributeValue('yes'); + vm.saveAttribute(); + + expect(providerState.setTrackNotes).toHaveBeenCalledWith(1, 'reviewed'); + expect(providerState.setTrackAttribute).toHaveBeenCalledWith(1, 'quality', 'high'); + expect(providerState.setTrackFirstFeatureAttribute) + .toHaveBeenCalledWith(1, 'occluded', 'yes'); + }); }); diff --git a/client/src/components/Tracks/bottombar/BottomBarTrackItemView.vue b/client/src/components/Tracks/bottombar/BottomBarTrackItemView.vue index 2bee29f4f..5c9129631 100644 --- a/client/src/components/Tracks/bottombar/BottomBarTrackItemView.vue +++ b/client/src/components/Tracks/bottombar/BottomBarTrackItemView.vue @@ -10,13 +10,13 @@ import { useReadOnlyMode, useTrackFilters, } from '../../../provides'; -import Track from '../../../track'; +import type { TrackProjection } from '../../../TrackProjection'; export default defineComponent({ name: 'BottomBarTrackItemView', components: { TooltipBtn }, props: { - track: { type: Object as PropType, required: true }, + track: { type: Object as PropType, required: true }, trackType: { type: String, required: true }, displayPairIndex: { type: Number, required: true }, itemStyle: { type: Object, required: true }, @@ -60,8 +60,7 @@ export default defineComponent({ }); const topConfidence = computed(() => { - if (props.track.revision.value !== undefined - && props.track.confidencePairs + if (props.track.confidencePairs && props.track.confidencePairs.length > 0) { return props.track.confidencePairs[props.displayPairIndex]?.[1] ?? null; } @@ -72,11 +71,9 @@ export default defineComponent({ if (localNotesDisplay.value) { return localNotesDisplay.value; } - if (props.track.revision.value !== undefined) { - const feature = props.track.features[props.track.begin]; - if (feature && feature.notes && feature.notes.length > 0) { - return feature.notes.join(', '); - } + const feature = props.track.features[props.track.begin]; + if (feature && feature.notes && feature.notes.length > 0) { + return feature.notes.join(', '); } return ''; }); @@ -106,8 +103,6 @@ export default defineComponent({ return localAttributeDisplay.value[attrKey]; } - if (props.track.revision.value === undefined) return ''; - if (attrKey.startsWith('track_')) { const name = attrKey.replace('track_', ''); const val = props.track.attributes[name]; @@ -199,7 +194,7 @@ export default defineComponent({ function saveNotes() { const newNotes = editNotesValue.value.trim(); - props.track.setFeatureNotes(props.track.begin, newNotes); + cameraStore.setTrackNotes(props.track.id, newNotes); localNotesDisplay.value = newNotes; editingNotes.value = false; } @@ -228,9 +223,13 @@ export default defineComponent({ const actualKey = attrKey.replace(/^(track_|detection_)/, ''); if (isTrackAttr) { - props.track.setAttribute(actualKey, newValue || undefined); + cameraStore.setTrackAttribute(props.track.id, actualKey, newValue || undefined); } else { - props.track.setFeatureAttribute(props.track.begin, actualKey, newValue || undefined); + cameraStore.setTrackFirstFeatureAttribute( + props.track.id, + actualKey, + newValue || undefined, + ); } localAttributeDisplay.value[attrKey] = newValue; diff --git a/client/src/components/Tracks/sidebar/SideBarTrackItemView.spec.ts b/client/src/components/Tracks/sidebar/SideBarTrackItemView.spec.ts index d774ca6ee..14f8fa565 100644 --- a/client/src/components/Tracks/sidebar/SideBarTrackItemView.spec.ts +++ b/client/src/components/Tracks/sidebar/SideBarTrackItemView.spec.ts @@ -5,7 +5,10 @@ import { shallowMount } from '@vue/test-utils'; import Track from '../../../track'; import SideBarTrackItemView from './SideBarTrackItemView.vue'; -const state = vi.hoisted(() => ({ assignTrackType: vi.fn() })); +const state = vi.hoisted(() => ({ + assignTrackType: vi.fn(), + setTrackNotes: vi.fn(), +})); vi.mock('../../../provides', () => ({ useHandler: () => ({ trackSeek: vi.fn(), removeTrack: vi.fn() }), @@ -18,6 +21,7 @@ vi.mock('../../../provides', () => ({ useCameraStore: () => ({ camMap: ref(new Map([['singleCam', {}]])), assignTrackType: state.assignTrackType, + setTrackNotes: state.setTrackNotes, }), useTrackStyleManager: () => ({ typeStyling: ref({}) }), })); @@ -47,7 +51,7 @@ function mountRow(props: Record) { } describe('SideBarTrackItemView classification editing', () => { - beforeEach(() => state.assignTrackType.mockClear()); + beforeEach(() => vi.clearAllMocks()); it('routes assignment through the logical-track command', () => { const track = new Track(1, { @@ -80,4 +84,36 @@ describe('SideBarTrackItemView classification editing', () => { replaceType: 'leaf', }); }); + + it('routes notes through the logical-track command', () => { + const track = new Track(1, { + begin: 0, + end: 0, + confidencePairs: [['root', 0.9]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }); + const { vm } = mountRow({ + selected: true, + trackType: 'root', + itemStyle: {}, + color: '#fff', + track, + inputValue: true, + isTrack: false, + feature: {}, + keyframeDisabled: true, + frame: 0, + toggleKeyframe: vi.fn(), + clickToggleInterpolation: vi.fn(), + toggleInterpolation: vi.fn(), + toggleAllInterpolation: vi.fn(), + gotoPrevious: vi.fn(), + gotoNext: vi.fn(), + editing: false, + }); + vm.editNotesValue = ' reviewed '; + vm.saveNotes(); + + expect(state.setTrackNotes).toHaveBeenCalledWith(1, 'reviewed'); + }); }); diff --git a/client/src/components/Tracks/sidebar/SideBarTrackItemView.vue b/client/src/components/Tracks/sidebar/SideBarTrackItemView.vue index e9dbd958e..46a7d1571 100644 --- a/client/src/components/Tracks/sidebar/SideBarTrackItemView.vue +++ b/client/src/components/Tracks/sidebar/SideBarTrackItemView.vue @@ -3,7 +3,7 @@ import { computed, defineComponent, PropType, ref, } from 'vue'; import context from 'dive-common/store/context'; -import Track from '../../../track'; +import type { TrackProjection } from 'vue-media-annotator/TrackProjection'; import TooltipBtn from '../../TooltipButton.vue'; import TypePicker from '../../TypePicker.vue'; import { @@ -24,7 +24,7 @@ export default defineComponent({ trackType: { type: String, required: true }, itemStyle: { type: Object, required: true }, color: { type: String, required: true }, - track: { type: Object as PropType, required: true }, + track: { type: Object as PropType, required: true }, inputValue: { type: Boolean, required: true }, disabled: { type: Boolean, default: false }, isTrack: { type: Boolean, required: true }, @@ -52,10 +52,6 @@ export default defineComponent({ const editNotesValue = ref(''); const currentNotes = computed(() => { - // Depend on revision so UI updates when notes change - if (props.track.revision.value === undefined) { - return ''; - } const feature = props.track.features[props.track.begin]; if (feature && feature.notes && feature.notes.length > 0) { return feature.notes.join(', '); @@ -95,7 +91,7 @@ export default defineComponent({ function saveNotes() { if (readOnlyMode.value) return; - props.track.setFeatureNotes(props.track.begin, editNotesValue.value.trim()); + cameraStore.setTrackNotes(props.track.id, editNotesValue.value.trim()); notesDialog.value = false; } diff --git a/client/src/track.ts b/client/src/track.ts index 08b1460a6..1ad6fb688 100644 --- a/client/src/track.ts +++ b/client/src/track.ts @@ -198,10 +198,7 @@ export default class Track extends BaseAnnotation { * Merge other into track at frame, preferring features from * self if there are conflicts */ - merge(others: Track[], disableNotifier = false) { - if (disableNotifier) { - this.notifierEnabled = false; - } + merge(others: Track[]) { others.forEach((other) => { other.confidencePairs.forEach((pair) => { const match = this.confidencePairs.find(([name]) => name === pair[0]); @@ -224,9 +221,6 @@ export default class Track extends BaseAnnotation { }); } }); - if (disableNotifier) { - this.notifierEnabled = true; - } } toggleKeyframe(frame: number) { diff --git a/docs/UI-Type-List.md b/docs/UI-Type-List.md index b3040f0dc..91b8de354 100644 --- a/docs/UI-Type-List.md +++ b/docs/UI-Type-List.md @@ -28,6 +28,8 @@ Assigning a type to a track is hierarchy-aware. A type together with its ancesto **Accept as correct** is a separate command: it sets the accepted pair to `1.0`, keeps stored ancestors and descendants at their existing scores, and removes unrelated pairs. Editing a confidence value to `1.0` does not accept the type or remove any other pair. +Track notes, track attributes, and first-detection attributes edited from a track row are logical-track values. They are written to every linked camera track, using each camera track's own first feature for feature-level values. Keyframe and interpolation controls edit geometry only in the selected camera. + Hierarchy members remain ordinary flat Type List rows. A parent with no annotations or explicit style configuration is visible when **Show Empty** is enabled. The Type List does not render a tree or offer subtree controls or hierarchy editing. While a hierarchy is active, **Prevent Cascade Types** is disabled and shows: `Not applicable to hierarchical types; DIVE selects the deepest qualifying type.` Its saved value is preserved and becomes active again when the hierarchy is removed.