Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions client/dive-common/components/Attributes/AttributesSubsection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions client/dive-common/components/TrackDetailsPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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,
}),
Expand Down
9 changes: 7 additions & 2 deletions client/dive-common/components/TrackDetailsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -442,7 +445,9 @@ export default defineComponent({
class="track-details"
>
<v-card
v-for="{ track, pair, pairIndex } in displayRows"
v-for="{
track, projection, pair, pairIndex,
} in displayRows"
:key="track.trackId"
class="mx-2 mb-2"
outlined
Expand All @@ -453,7 +458,7 @@ export default defineComponent({
v-if="pair"
:solo="true"
:merging="multiSelectInProgress"
:track="track"
:track="projection"
:track-type="pair[0]"
:display-pair-index="pairIndex"
:selected="selectedTrackIdRef === track.id"
Expand Down
8 changes: 4 additions & 4 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ export default defineComponent({
cameraStore.setTrackType(id, newType, confidenceVal, currentType);
};
const removeTypes = (id: AnnotationId, types: string[]) => 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),
Expand Down Expand Up @@ -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({
Expand All @@ -803,7 +803,7 @@ export default defineComponent({
}
return [];
}),
getTracksMerged,
getTrackProjection,
});

async function trackSplit(trackId: AnnotationId | null, frame: number) {
Expand Down
4 changes: 2 additions & 2 deletions client/dive-common/use/useModeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ export default function useModeManager({
: interpolateTrack;
}

function seekNearest(track: Track) {
function seekNearest(track: Readonly<Pick<Track, 'begin' | 'end'>>) {
// 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
Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 1 addition & 5 deletions client/src/BaseAnnotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ export default abstract class BaseAnnotation {
/** A callback to notify about changes to the track. */
notifier?: NotifierFunc<this>;

/** Enables/Disables the notifier specifically for multicam merge */
notifierEnabled: boolean;

constructor(id: AnnotationId, {
meta = {},
begin = Infinity,
Expand All @@ -74,7 +71,6 @@ export default abstract class BaseAnnotation {
this.begin = begin;
this.end = end;
this.confidencePairs = confidencePairs;
this.notifierEnabled = true;
}

get length() {
Expand Down Expand Up @@ -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({
Expand Down
219 changes: 219 additions & 0 deletions client/src/CameraStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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],
Expand Down Expand Up @@ -148,3 +152,218 @@ 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('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 });
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]]));
});
});

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']);
});
});
Loading
Loading