diff --git a/client/dive-common/components/TrackDetailsPanel.spec.ts b/client/dive-common/components/TrackDetailsPanel.spec.ts
index 952a2d73a..f5880a572 100644
--- a/client/dive-common/components/TrackDetailsPanel.spec.ts
+++ b/client/dive-common/components/TrackDetailsPanel.spec.ts
@@ -8,6 +8,9 @@ import TrackDetailsPanel from './TrackDetailsPanel.vue';
const state = vi.hoisted(() => ({
displayPairIndex: vi.fn(() => 1),
track: null as Track | null,
+ multiSelectList: [] as number[],
+ acceptTrackType: vi.fn(),
+ assignTrackType: vi.fn(),
}));
vi.mock('vue-media-annotator/provides', () => ({
@@ -26,9 +29,10 @@ vi.mock('vue-media-annotator/provides', () => ({
useTrackFilters: () => ({
allTypes: ref(['root', 'leaf']),
displayPairIndex: state.displayPairIndex,
+ hierarchyIndex: ref(undefined),
}),
useAttributes: () => ref([]),
- useMultiSelectList: () => ref([]),
+ useMultiSelectList: () => ref(state.multiSelectList),
useTime: () => ({ frame: ref(0) }),
useReadOnlyMode: () => ref(false),
useTrackStyleManager: () => ({
@@ -41,7 +45,8 @@ vi.mock('vue-media-annotator/provides', () => ({
camMap: ref(new Map([['singleCam', { groupStore: undefined }]])),
getAnyTrack: () => state.track,
getAnyPossibleTrack: () => state.track,
- setTrackType: vi.fn(),
+ acceptTrackType: state.acceptTrackType,
+ assignTrackType: state.assignTrackType,
}),
useSelectedCamera: () => ref('singleCam'),
}));
@@ -73,6 +78,9 @@ function mountPanel() {
describe('TrackDetailsPanel hierarchy summary', () => {
beforeEach(() => {
state.displayPairIndex.mockReturnValue(1);
+ state.multiSelectList = [];
+ state.acceptTrackType.mockClear();
+ state.assignTrackType.mockClear();
state.track = new Track(1, {
confidencePairs: [['root', 0.9], ['leaf', 0.7]],
features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }],
@@ -106,4 +114,21 @@ describe('TrackDetailsPanel hierarchy summary', () => {
const { wrapper } = mountPanel();
expect(wrapper.findComponent({ name: 'TrackItem' }).exists()).toBe(false);
});
+
+ it('routes Accept Correct Type through the acceptance command', () => {
+ const { vm } = mountPanel();
+ vm.acceptTrackType('leaf');
+ expect(state.acceptTrackType).toHaveBeenCalledWith(1, 'leaf', undefined);
+ });
+
+ it('routes bulk assignment through the same hierarchy-aware command', () => {
+ state.multiSelectList = [1];
+ const { vm } = mountPanel();
+ vm.updateMultiTrackType('new leaf');
+ vm.updateSelectedTracksType();
+ expect(state.assignTrackType).toHaveBeenCalledWith(1, 'new leaf', {
+ hierarchyIndex: undefined,
+ replaceType: 'leaf',
+ });
+ });
});
diff --git a/client/dive-common/components/TrackDetailsPanel.vue b/client/dive-common/components/TrackDetailsPanel.vue
index f5b40bd33..40bfa1a41 100644
--- a/client/dive-common/components/TrackDetailsPanel.vue
+++ b/client/dive-common/components/TrackDetailsPanel.vue
@@ -238,8 +238,12 @@ export default defineComponent({
});
function updateSelectedTracksType() {
- multiSelectList.value.forEach((trackId: number) => {
- cameraStore.setTrackType(trackId, multiTrackType.value);
+ selectedTrackList.value.forEach((track) => {
+ const pairIndex = Math.max(trackFilters.displayPairIndex(track, 0), 0);
+ cameraStore.assignTrackType(track.id, multiTrackType.value, {
+ hierarchyIndex: trackFilters.hierarchyIndex.value,
+ replaceType: track.confidencePairs[pairIndex]?.[0],
+ });
});
}
@@ -254,11 +258,10 @@ export default defineComponent({
return pairs.sort((a, b) => b[1] - a[1]);
});
- function setTrackType(type: string) {
+ function acceptTrackType(type: string) {
const track = selectedTrackList.value[0];
if (!track) return;
- const currentType = track.confidencePairs[0]?.[0];
- cameraStore.setTrackType(track.id, type, 1, currentType);
+ cameraStore.acceptTrackType(track.id, type, trackFilters.hierarchyIndex.value);
}
const displayRows = computed(() => selectedTrackList.value.map((track) => {
@@ -314,7 +317,7 @@ export default defineComponent({
toggleMerge,
unstageFromMerge,
updateSelectedTracksType,
- setTrackType,
+ acceptTrackType,
displayConfidencePairs,
displayRows,
trackFilters,
@@ -626,7 +629,7 @@ export default defineComponent({
:confidence-pairs="displayConfidencePairs"
:disabled="selectedTrackList.length > 1"
:user-modified="isUserModified"
- @set-type="setTrackType($event)"
+ @set-type="acceptTrackType($event)"
/>
(cameraStore.getTrack(track, camera)),
getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track),
+ renameTrackPair: (id, currentType, newType) => (
+ cameraStore.renameTrackPair(id, currentType, newType)
+ ),
groupFilterControls: groupFilters,
setType: setTrackType,
removeTypes,
diff --git a/client/dive-common/typeHierarchy.spec.ts b/client/dive-common/typeHierarchy.spec.ts
index 1a9778293..274e0998f 100644
--- a/client/dive-common/typeHierarchy.spec.ts
+++ b/client/dive-common/typeHierarchy.spec.ts
@@ -1,10 +1,14 @@
import fs from 'fs-extra';
import {
+ acceptPairAsCorrect,
compileHierarchy,
normalizeTypeHierarchy,
+ reassignPairs,
+ removePair,
resolveTypeHierarchy,
rewriteHierarchyType,
selectPairIndex,
+ setPairConfidence,
TypeHierarchyError,
} from './typeHierarchy';
@@ -45,11 +49,48 @@ interface SelectionCase {
expectedIndex: number;
}
+interface ReassignmentCase {
+ name: string;
+ hierarchy: Record;
+ pairs: [string, number][];
+ replaceType: string;
+ newType: string;
+ confidence: number;
+ expected: [string, number][];
+}
+
+interface AcceptanceCase {
+ name: string;
+ hierarchy: Record;
+ pairs: [string, number][];
+ acceptedType: string;
+ expected: [string, number][];
+}
+
+interface PairConfidenceCase {
+ name: string;
+ pairs: [string, number][];
+ type: string;
+ confidence: number;
+ expected: [string, number][];
+}
+
+interface PairRemovalCase {
+ name: string;
+ pairs: [string, number][];
+ type: string;
+ expected: [string, number][];
+}
+
interface TypeHierarchyCorpus {
normalizationCases: NormalizationCase[];
resolutionCases: ResolutionCase[];
renameCases: RenameCase[];
selectionCases: SelectionCase[];
+ reassignmentCases: ReassignmentCase[];
+ acceptanceCases: AcceptanceCase[];
+ pairConfidenceCases: PairConfidenceCase[];
+ pairRemovalCases: PairRemovalCase[];
}
const corpus = fs.readJSONSync('../testutils/typeHierarchy.spec.json') as TypeHierarchyCorpus;
@@ -132,6 +173,46 @@ describe('shared type hierarchy corpus', () => {
)).toBe(testCase.expectedIndex);
});
});
+
+ describe.each(corpus.reassignmentCases)('reassignment: $name', (testCase) => {
+ it('matches the shared result', () => {
+ const hierarchy = normalizeTypeHierarchy(testCase.hierarchy) || {};
+ expect(reassignPairs(
+ compileHierarchy(hierarchy),
+ testCase.pairs,
+ testCase.replaceType,
+ testCase.newType,
+ testCase.confidence,
+ )).toEqual(testCase.expected);
+ });
+ });
+
+ describe.each(corpus.acceptanceCases)('acceptance: $name', (testCase) => {
+ it('matches the shared result', () => {
+ const hierarchy = normalizeTypeHierarchy(testCase.hierarchy) || {};
+ expect(acceptPairAsCorrect(
+ compileHierarchy(hierarchy),
+ testCase.pairs,
+ testCase.acceptedType,
+ )).toEqual(testCase.expected);
+ });
+ });
+
+ describe.each(corpus.pairConfidenceCases)('pair confidence: $name', (testCase) => {
+ it('matches the shared result', () => {
+ expect(setPairConfidence(
+ testCase.pairs,
+ testCase.type,
+ testCase.confidence,
+ )).toEqual(testCase.expected);
+ });
+ });
+
+ describe.each(corpus.pairRemovalCases)('pair removal: $name', (testCase) => {
+ it('matches the shared result', () => {
+ expect(removePair(testCase.pairs, testCase.type)).toEqual(testCase.expected);
+ });
+ });
});
describe('type hierarchy index', () => {
@@ -148,4 +229,21 @@ describe('type hierarchy index', () => {
'passes and pairs must have the same length',
);
});
+
+ it('classification operations do not mutate or reuse their input pairs', () => {
+ const pairs: [string, number][] = [['cod', 0.8], ['fish', 0.7], ['bird', 0.2]];
+ const snapshot = pairs.map(([type, confidence]) => [type, confidence] as [string, number]);
+ const results = [
+ reassignPairs(index, pairs, 'cod', 'haddock', 0.8),
+ acceptPairAsCorrect(index, pairs, 'cod'),
+ setPairConfidence(pairs, 'cod', 1.0),
+ removePair(pairs, 'bird'),
+ ];
+
+ expect(pairs).toEqual(snapshot);
+ results.forEach((result) => {
+ expect(result).not.toBe(pairs);
+ result.forEach((pair) => expect(pairs).not.toContain(pair));
+ });
+ });
});
diff --git a/client/dive-common/typeHierarchy.ts b/client/dive-common/typeHierarchy.ts
index c6649ee81..3dc4c761c 100644
--- a/client/dive-common/typeHierarchy.ts
+++ b/client/dive-common/typeHierarchy.ts
@@ -224,6 +224,20 @@ function ancestorsOf(index: TypeHierarchyIndex, type: string): readonly string[]
: [];
}
+function sortPairsByConfidence(
+ pairs: readonly (readonly [string, number])[],
+): [string, number][] {
+ return pairs
+ .map(([type, confidence]) => [type, confidence] as [string, number])
+ .sort((left, right) => right[1] - left[1]);
+}
+
+function inLineage(index: TypeHierarchyIndex, type: string, lineageType: string): boolean {
+ return type === lineageType
+ || ancestorsOf(index, lineageType).includes(type)
+ || ancestorsOf(index, type).includes(lineageType);
+}
+
export function rewriteHierarchyType(
hierarchy: TypeHierarchy,
currentType: string,
@@ -264,6 +278,55 @@ export function rewriteHierarchyType(
}
}
+// Assignment replaces the selected claim's lineage while retaining unrelated claims and the
+// stored ancestors still implied by the new type. No missing hierarchy members are synthesized.
+export function reassignPairs(
+ index: TypeHierarchyIndex,
+ pairs: readonly (readonly [string, number])[],
+ replaceType: string,
+ newType: string,
+ confidence: number,
+): [string, number][] {
+ const newAncestors = ancestorsOf(index, newType);
+ const retained = pairs.filter(([type]) => type !== newType
+ && (newAncestors.includes(type) || !inLineage(index, type, replaceType)));
+ return sortPairsByConfidence([...retained, [newType, confidence]]);
+}
+
+// Acceptance is deliberately distinct from assignment: it keeps only the accepted lineage and
+// changes only the accepted node's score. The accepted node itself is upserted, but absent
+// ancestors and descendants remain absent.
+export function acceptPairAsCorrect(
+ index: TypeHierarchyIndex,
+ pairs: readonly (readonly [string, number])[],
+ acceptedType: string,
+): [string, number][] {
+ return setPairConfidence(pairs, acceptedType, 1.0)
+ .filter(([type]) => inLineage(index, type, acceptedType));
+}
+
+// Confidence is data, not an instruction. In particular, 1.0 has no destructive behavior.
+export function setPairConfidence(
+ pairs: readonly (readonly [string, number])[],
+ type: string,
+ confidence: number,
+): [string, number][] {
+ return sortPairsByConfidence([
+ ...pairs.filter(([pairType]) => pairType !== type),
+ [type, confidence],
+ ]);
+}
+
+// Pair removal is exact; lineage, subtree, and hierarchy-node removal are separate operations.
+export function removePair(
+ pairs: readonly (readonly [string, number])[],
+ type: string,
+): [string, number][] {
+ return pairs
+ .filter(([pairType]) => pairType !== type)
+ .map(([pairType, confidence]) => [pairType, confidence]);
+}
+
export function selectPairIndex(
index: TypeHierarchyIndex,
pairs: readonly (readonly [string, number])[],
diff --git a/client/dive-common/use/useModeManager.spec.ts b/client/dive-common/use/useModeManager.spec.ts
index 798c86838..13c10390a 100644
--- a/client/dive-common/use/useModeManager.spec.ts
+++ b/client/dive-common/use/useModeManager.spec.ts
@@ -60,6 +60,9 @@ function makeHarness() {
lookupGroups: cameraStore.lookupGroups.bind(cameraStore),
getTrack: (id: AnnotationId, camera = 'singleCam') => cameraStore.getTrack(id, camera),
getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id),
+ renameTrackPair: (id, currentType, newType) => (
+ cameraStore.renameTrackPair(id, currentType, newType)
+ ),
groupFilterControls,
setType: () => undefined,
removeTypes: () => [],
@@ -197,6 +200,9 @@ function makeSingleCamHarness() {
lookupGroups: cameraStore.lookupGroups.bind(cameraStore),
getTrack: (id: AnnotationId, camera = 'singleCam') => cameraStore.getTrack(id, camera),
getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id),
+ renameTrackPair: (id, currentType, newType) => (
+ cameraStore.renameTrackPair(id, currentType, newType)
+ ),
groupFilterControls,
setType: () => undefined,
removeTypes: () => [],
diff --git a/client/dive-common/use/useSaveClassification.spec.ts b/client/dive-common/use/useSaveClassification.spec.ts
new file mode 100644
index 000000000..c6f9279bd
--- /dev/null
+++ b/client/dive-common/use/useSaveClassification.spec.ts
@@ -0,0 +1,115 @@
+///
+import { ref } from 'vue';
+
+import CameraStore from 'vue-media-annotator/CameraStore';
+import Track, { Feature, TrackData } from 'vue-media-annotator/track';
+
+import useSave from './useSave';
+
+const apiMocks = vi.hoisted(() => ({
+ saveConfig: vi.fn(),
+ saveDetections: vi.fn(),
+ saveAttributes: vi.fn(),
+ saveAttributeTrackFilters: vi.fn(),
+}));
+
+vi.mock('dive-common/apispec', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useApi: () => apiMocks,
+ };
+});
+
+const TRACK_ID = 7;
+
+function features(): Feature[] {
+ return [{
+ frame: 0,
+ keyframe: true,
+ bounds: [0, 0, 1, 1],
+ }];
+}
+
+function makeTrack(confidencePairs: [string, number][]) {
+ return new Track(TRACK_ID, {
+ confidencePairs,
+ features: features(),
+ });
+}
+
+function savedTrack(call: unknown[]): TrackData {
+ const payload = call[1] as {
+ tracks: { upsert: TrackData[] };
+ };
+ return payload.tracks.upsert[0];
+}
+
+describe('classification save and reload', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ apiMocks.saveConfig.mockResolvedValue(undefined);
+ apiMocks.saveDetections.mockResolvedValue(undefined);
+ apiMocks.saveAttributes.mockResolvedValue(undefined);
+ apiMocks.saveAttributeTrackFilters.mockResolvedValue(undefined);
+ });
+
+ it('persists an ordinary 1.0 confidence edit for a single camera', async () => {
+ const saveControls = useSave(ref('single-dataset'), ref(false));
+ const cameraStore = new CameraStore({
+ markChangesPending: saveControls.markChangesPending,
+ });
+ cameraStore.camMap.value.get('singleCam')?.trackStore.insert(makeTrack([
+ ['fish', 0.8],
+ ['shark', 0.2],
+ ]), { imported: true });
+
+ cameraStore.setTrackPairConfidence(TRACK_ID, 'shark', 1.0);
+ await saveControls.save();
+
+ expect(apiMocks.saveDetections).toHaveBeenCalledTimes(1);
+ expect(apiMocks.saveDetections.mock.calls[0][0]).toBe('single-dataset');
+ const serialized = savedTrack(apiMocks.saveDetections.mock.calls[0]);
+ const reloaded = Track.fromJSON(serialized);
+ expect(reloaded.confidencePairs).toEqual([
+ ['shark', 1.0],
+ ['fish', 0.8],
+ ]);
+ });
+
+ it('persists synchronized independent vectors for every camera', async () => {
+ const saveControls = useSave(ref('multicam-dataset'), ref(false));
+ saveControls.removeCamera('singleCam');
+ saveControls.addCamera('left');
+ saveControls.addCamera('right');
+ const cameraStore = new CameraStore({
+ markChangesPending: saveControls.markChangesPending,
+ });
+ cameraStore.removeCamera('singleCam');
+ cameraStore.addCamera('left');
+ cameraStore.addCamera('right');
+ cameraStore.camMap.value.get('left')?.trackStore.insert(makeTrack([
+ ['fish', 0.8],
+ ['shark', 0.2],
+ ]), { imported: true });
+ cameraStore.camMap.value.get('right')?.trackStore.insert(makeTrack([
+ ['rock', 0.9],
+ ['fish', 0.1],
+ ]), { imported: true });
+
+ cameraStore.acceptTrackType(TRACK_ID, 'fish');
+ await saveControls.save();
+
+ expect(apiMocks.saveDetections.mock.calls.map(([datasetId]) => datasetId))
+ .toEqual(['multicam-dataset/left', 'multicam-dataset/right']);
+ const [leftData, rightData] = apiMocks.saveDetections.mock.calls.map(savedTrack);
+ const leftReloaded = Track.fromJSON(leftData);
+ const rightReloaded = Track.fromJSON(rightData);
+ expect(leftReloaded.confidencePairs).toEqual([['fish', 1.0]]);
+ expect(rightReloaded.confidencePairs).toEqual(leftReloaded.confidencePairs);
+ expect(rightReloaded.confidencePairs).not.toBe(leftReloaded.confidencePairs);
+ rightReloaded.confidencePairs.forEach((pair) => (
+ expect(leftReloaded.confidencePairs).not.toContain(pair)
+ ));
+ });
+});
diff --git a/client/src/BaseAnnotation.ts b/client/src/BaseAnnotation.ts
index 988305e16..0f925b570 100644
--- a/client/src/BaseAnnotation.ts
+++ b/client/src/BaseAnnotation.ts
@@ -140,6 +140,12 @@ export default abstract class BaseAnnotation {
return this.confidencePairs;
}
+ setConfidencePairs(pairs: readonly (readonly [string, number])[]) {
+ const old = this.confidencePairs;
+ this.confidencePairs = pairs.map(([type, confidence]) => [type, confidence]);
+ this.notify('confidencePairs', old);
+ }
+
setType(annotationType: string, confidenceVal = 1, replace: string | undefined = undefined) {
const old = this.confidencePairs;
if (confidenceVal >= 1) {
diff --git a/client/src/CameraStore.spec.ts b/client/src/CameraStore.spec.ts
new file mode 100644
index 000000000..d75083e2d
--- /dev/null
+++ b/client/src/CameraStore.spec.ts
@@ -0,0 +1,150 @@
+///
+import { compileHierarchy } from 'dive-common/typeHierarchy';
+import CameraStore from './CameraStore';
+import Track, { Feature } from './track';
+
+const HIERARCHY_INDEX = compileHierarchy({
+ 'great white shark': 'shark',
+ shark: 'fish',
+ tern: 'bird',
+});
+
+const TRACK_ID = 7;
+
+function features(): Feature[] {
+ return [{
+ frame: 0,
+ keyframe: true,
+ bounds: [0, 0, 1, 1],
+ }];
+}
+
+function confidencePairs(value: [string, number][]): [string, number][] {
+ return value.map(([type, confidence]) => [type, confidence]);
+}
+
+function makeTwoCameraStore() {
+ const markChangesPending = vi.fn();
+ const store = new CameraStore({ markChangesPending });
+ store.removeCamera('singleCam');
+ store.addCamera('left');
+ store.addCamera('right');
+
+ const left = new Track(TRACK_ID, {
+ confidencePairs: confidencePairs([
+ ['fish', 0.9],
+ ['shark', 0.7],
+ ['great white shark', 0.4],
+ ['bird', 0.2],
+ ]),
+ features: features(),
+ });
+ const right = new Track(TRACK_ID, {
+ confidencePairs: confidencePairs([
+ ['rock', 0.95],
+ ['shark', 0.1],
+ ]),
+ features: features(),
+ });
+ store.camMap.value.get('left')?.trackStore.insert(left, { imported: true });
+ store.camMap.value.get('right')?.trackStore.insert(right, { imported: true });
+ markChangesPending.mockClear();
+ return {
+ store, left, right, markChangesPending,
+ };
+}
+
+function expectSynchronizedWrite(
+ fixture: ReturnType,
+ result: [string, number][],
+ expected: [string, number][],
+) {
+ const {
+ left, right, markChangesPending,
+ } = fixture;
+ expect(result).toEqual(expected);
+ expect(left.confidencePairs).toEqual(expected);
+ expect(right.confidencePairs).toEqual(expected);
+ expect(left.confidencePairs).not.toBe(right.confidencePairs);
+ expect(result).not.toBe(left.confidencePairs);
+ expect(result).not.toBe(right.confidencePairs);
+ left.confidencePairs.forEach((pair) => expect(right.confidencePairs).not.toContain(pair));
+ result.forEach((pair) => expect(left.confidencePairs).not.toContain(pair));
+ expect(markChangesPending).toHaveBeenCalledTimes(2);
+ expect(markChangesPending.mock.calls.map(([change]) => change.cameraName))
+ .toEqual(['left', 'right']);
+}
+
+describe('CameraStore classification commands', () => {
+ it('calculates assignment once from the first camera and synchronizes independent copies', () => {
+ const fixture = makeTwoCameraStore();
+ const result = fixture.store.assignTrackType(TRACK_ID, 'great white shark', {
+ hierarchyIndex: HIERARCHY_INDEX,
+ replaceType: 'shark',
+ confidence: 0.7,
+ });
+
+ expectSynchronizedWrite(fixture, result, [
+ ['fish', 0.9],
+ ['shark', 0.7],
+ ['great white shark', 0.7],
+ ['bird', 0.2],
+ ]);
+ });
+
+ it('accepts a hierarchy node without changing stored relative scores', () => {
+ const fixture = makeTwoCameraStore();
+ const result = fixture.store.acceptTrackType(TRACK_ID, 'shark', HIERARCHY_INDEX);
+
+ expectSynchronizedWrite(fixture, result, [
+ ['shark', 1.0],
+ ['fish', 0.9],
+ ['great white shark', 0.4],
+ ]);
+ });
+
+ it.each([0.99, 1.0])(
+ 'treats confidence %s as a single-pair update',
+ (confidence) => {
+ const fixture = makeTwoCameraStore();
+ const result = fixture.store.setTrackPairConfidence(TRACK_ID, 'shark', confidence);
+
+ expectSynchronizedWrite(fixture, result, [
+ ['shark', confidence],
+ ['fish', 0.9],
+ ['great white shark', 0.4],
+ ['bird', 0.2],
+ ]);
+ },
+ );
+
+ it('removes exactly one type and returns the logical result', () => {
+ const fixture = makeTwoCameraStore();
+ const result = fixture.store.removeTrackPair(TRACK_ID, 'shark');
+
+ expectSynchronizedWrite(fixture, result, [
+ ['fish', 0.9],
+ ['great white shark', 0.4],
+ ['bird', 0.2],
+ ]);
+ });
+
+ it('renames one pair without applying assignment or acceptance semantics', () => {
+ const fixture = makeTwoCameraStore();
+ const result = fixture.store.renameTrackPair(TRACK_ID, 'shark', 'selachimorpha');
+
+ expectSynchronizedWrite(fixture, result, [
+ ['fish', 0.9],
+ ['selachimorpha', 0.7],
+ ['great white shark', 0.4],
+ ['bird', 0.2],
+ ]);
+ });
+
+ it('rejects a classification command for a missing logical track', () => {
+ const fixture = makeTwoCameraStore();
+ expect(() => fixture.store.setTrackPairConfidence(99, 'fish', 1.0))
+ .toThrow('TrackId 99 not found in any camera');
+ expect(fixture.markChangesPending).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts
index d313806a8..9b7b12ba6 100644
--- a/client/src/CameraStore.ts
+++ b/client/src/CameraStore.ts
@@ -2,6 +2,14 @@ import {
Ref, computed, shallowRef, triggerRef,
} from 'vue';
import { cloneDeep, uniq } from 'lodash';
+import {
+ acceptPairAsCorrect,
+ compileHierarchy,
+ reassignPairs,
+ removePair,
+ setPairConfidence,
+ TypeHierarchyIndex,
+} from 'dive-common/typeHierarchy';
import type Track from './track';
import type Group from './Group';
import { AnnotationId, ConfidencePair } from './BaseAnnotation';
@@ -9,6 +17,14 @@ import { MarkChangesPending, SortedAnnotation } from './BaseAnnotationStore';
import GroupStore from './GroupStore';
import TrackStore from './TrackStore';
+const FLAT_HIERARCHY_INDEX = compileHierarchy({});
+
+interface TrackAssignmentOptions {
+ hierarchyIndex?: TypeHierarchyIndex;
+ replaceType?: string;
+ confidence?: number;
+}
+
/**
* CameraStore is a warapper for holding and collating tracks from multiple cameras.
* If a singleCamera is in operation it uses the root 'singleCam' with a single store.
@@ -243,6 +259,79 @@ export default class CameraStore {
});
}
+ private updateTrackConfidencePairs(
+ id: AnnotationId,
+ update: (pairs: readonly ConfidencePair[]) => ConfidencePair[],
+ ): ConfidencePair[] {
+ const tracks = this.getTrackAll(id);
+ if (tracks.length === 0) {
+ throw new Error(`TrackId ${id} not found in any camera`);
+ }
+ const canonicalPairs = tracks[0].confidencePairs
+ .map(([type, confidence]) => [type, confidence] as ConfidencePair);
+ const nextPairs = update(canonicalPairs);
+ tracks.forEach((track) => track.setConfidencePairs(nextPairs));
+ return nextPairs.map(([type, confidence]) => [type, confidence]);
+ }
+
+ assignTrackType(
+ id: AnnotationId,
+ newType: string,
+ {
+ hierarchyIndex = FLAT_HIERARCHY_INDEX,
+ replaceType,
+ confidence = 1,
+ }: TrackAssignmentOptions = {},
+ ): ConfidencePair[] {
+ return this.updateTrackConfidencePairs(id, (pairs) => reassignPairs(
+ hierarchyIndex,
+ pairs,
+ replaceType ?? pairs[0]?.[0] ?? newType,
+ newType,
+ confidence,
+ ));
+ }
+
+ acceptTrackType(
+ id: AnnotationId,
+ acceptedType: string,
+ hierarchyIndex: TypeHierarchyIndex = FLAT_HIERARCHY_INDEX,
+ ): ConfidencePair[] {
+ return this.updateTrackConfidencePairs(
+ id,
+ (pairs) => acceptPairAsCorrect(hierarchyIndex, pairs, acceptedType),
+ );
+ }
+
+ setTrackPairConfidence(
+ id: AnnotationId,
+ type: string,
+ confidence: number,
+ ): ConfidencePair[] {
+ return this.updateTrackConfidencePairs(
+ id,
+ (pairs) => setPairConfidence(pairs, type, confidence),
+ );
+ }
+
+ removeTrackPair(id: AnnotationId, type: string): ConfidencePair[] {
+ return this.updateTrackConfidencePairs(id, (pairs) => removePair(pairs, type));
+ }
+
+ renameTrackPair(
+ id: AnnotationId,
+ currentType: string,
+ newType: string,
+ ): ConfidencePair[] {
+ return this.updateTrackConfidencePairs(id, (pairs) => {
+ const current = pairs.find(([type]) => type === currentType);
+ if (!current) {
+ return pairs.map(([type, confidence]) => [type, confidence]);
+ }
+ return setPairConfidence(removePair(pairs, currentType), newType, current[1]);
+ });
+ }
+
removeTypes(id: AnnotationId, types: string[]) {
let resultingTypes: ConfidencePair[] = [];
this.camMap.value.forEach((camera) => {
diff --git a/client/src/TrackFilterControls.spec.ts b/client/src/TrackFilterControls.spec.ts
index 603440db5..ec7d6f750 100644
--- a/client/src/TrackFilterControls.spec.ts
+++ b/client/src/TrackFilterControls.spec.ts
@@ -111,6 +111,9 @@ function makeTrackFilterControls(markPending: MarkChangesPendingFilter = markCha
lookupGroups: cameraStore.lookupGroups,
getTrack: (track: AnnotationId, camera = 'singleCam') => (cameraStore.getTrack(track, camera)),
getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track),
+ renameTrackPair: (id, currentType, newType) => (
+ cameraStore.renameTrackPair(id, currentType, newType)
+ ),
setType: setTrackType,
removeTypes,
});
@@ -135,6 +138,9 @@ function makePairFixture(
lookupGroups: cameraStore.lookupGroups,
getTrack: (id, camera = 'singleCam') => cameraStore.getTrack(id, camera),
getTracks: (id) => cameraStore.getTrackAll(id),
+ renameTrackPair: (id, currentType, newType) => (
+ cameraStore.renameTrackPair(id, currentType, newType)
+ ),
setType: (id, type, confidence, current) => (
cameraStore.setTrackType(id, type, confidence, current)
),
@@ -555,6 +561,16 @@ describe('useAnnotationFilters', () => {
expect(groupFilters.configuredTypes.value).not.toContain('renamed group');
});
+ it('renames a flat confidence-1 pair without collapsing the vector', () => {
+ const { cameraStore, filters } = makePairFixture([
+ [['leaf', 1], ['other', 0.4]],
+ ]);
+ filters.updateTypeName({ currentType: 'leaf', newType: 'fin' });
+ expect(cameraStore.getTrack(0).confidencePairs).toEqual([
+ ['fin', 1], ['other', 0.4],
+ ]);
+ });
+
it('rewrites hierarchy, annotations, configured types, filters, and checks on rename', () => {
const markPending = vi.fn();
const { cameraStore, filters } = makePairFixture([[['leaf', 0.8], ['root', 0.7]]], markPending);
@@ -591,7 +607,7 @@ describe('useAnnotationFilters', () => {
]);
});
- it('preserves each camera confidence vector while renaming exact occurrences', () => {
+ it('renames from the canonical vector and synchronizes every camera', () => {
const { cameraStore, filters } = makePairFixture([
[['leaf', 1], ['root', 0.8]],
]);
@@ -606,8 +622,10 @@ describe('useAnnotationFilters', () => {
['fin', 1], ['root', 0.8],
]);
expect(cameraStore.getTrack(0, 'right').confidencePairs).toEqual([
- ['other', 0.6], ['fin', 0.4],
+ ['fin', 1], ['root', 0.8],
]);
+ expect(cameraStore.getTrack(0, 'singleCam').confidencePairs)
+ .not.toBe(cameraStore.getTrack(0, 'right').confidencePairs);
});
it('rejects invalid hierarchy renames before any mutation or pending event', () => {
diff --git a/client/src/TrackFilterControls.ts b/client/src/TrackFilterControls.ts
index 893722f7d..e47e9a2a4 100644
--- a/client/src/TrackFilterControls.ts
+++ b/client/src/TrackFilterControls.ts
@@ -25,6 +25,11 @@ interface TrackFilterControlsParams extends FilterControlsParams