diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index a00fd15bf..ce0f906be 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -18,6 +18,7 @@ import { import { Track, Group, CameraStore, + formatDivergentClassificationWarning, CameraRegistrationStore, AlignedViewStore, StyleManager, TrackFilterControls, GroupFilterControls, @@ -647,13 +648,24 @@ export default defineComponent({ cameraStore.setTrackType(id, newType, confidenceVal, currentType); }; const removeTypes = (id: AnnotationId, types: string[]) => cameraStore.removeTypes(id, types); + const setGroupType = ( + id: AnnotationId, + newType: string, + confidenceVal?: number, + currentType?: string, + ) => { + cameraStore.setGroupType(id, newType, confidenceVal, currentType); + }; + const removeGroupTypes = (id: AnnotationId, types: string[]) => ( + cameraStore.removeGroupTypes(id, types) + ); const getTrackProjection = (id: AnnotationId) => cameraStore.getTrackProjection(id); const groupFilters = new GroupFilterControls({ sorted: cameraStore.sortedGroups, markChangesPending: (markChangesPending as MarkChangesPendingFilter), remove: removeGroups, - setType: setTrackType, - removeTypes, + setType: setGroupType, + removeTypes: removeGroupTypes, }); // This context for removal @@ -1533,6 +1545,7 @@ export default defineComponent({ multiCamList.value = ['singleCam']; resetMulticamAlignment(); } + cameraStore.setCameraOrder(multiCamList.value); /* Otherwise, complete loading of the dataset */ /** * When shared colors are enabled, overlay the cross-dataset styles on @@ -1781,6 +1794,22 @@ export default defineComponent({ removeSaveCamera(key); } }); + if (multiCamList.value.length > 1 && props.comparisonSets.length === 0) { + const divergenceWarning = formatDivergentClassificationWarning( + cameraStore.divergentClassificationTrackIds(), + ); + if (divergenceWarning) { + trackFilters.queueLoadWarning(divergenceWarning); + const loadWarning = trackFilters.consumeLoadWarning(); + if (loadWarning) { + await prompt({ + title: 'Divergent Track Classifications', + text: loadWarning, + positiveButton: 'OK', + }); + } + } + } // Needs to be done after the cameraMap is created if (meta.attributeTrackFilters) { trackFilters.loadTrackAttributesFilter(Object.values(meta.attributeTrackFilters)); diff --git a/client/dive-common/typeHierarchy.spec.ts b/client/dive-common/typeHierarchy.spec.ts index 274e0998f..3d2d2d4a5 100644 --- a/client/dive-common/typeHierarchy.spec.ts +++ b/client/dive-common/typeHierarchy.spec.ts @@ -2,6 +2,7 @@ import fs from 'fs-extra'; import { acceptPairAsCorrect, compileHierarchy, + mergePairs, normalizeTypeHierarchy, reassignPairs, removePair, @@ -247,3 +248,48 @@ describe('type hierarchy index', () => { }); }); }); + +describe('pair merging', () => { + const cases: [Array<[string, number][]>, [string, number][]][] = [ + [ + [[['fish', 0.7], ['shark', 1.0]], [['fish', 0.9], ['bird', 0.4]]], + [['shark', 1.0], ['fish', 0.9], ['bird', 0.4]], + ], + [ + [[['bird', 0.4], ['fish', 0.9]], [['shark', 1.0], ['fish', 0.7]]], + [['shark', 1.0], ['fish', 0.9], ['bird', 0.4]], + ], + ]; + + it.each(cases)( + 'unions names and keeps the maximum duplicate score independent of input order', + (inputs, expected) => { + expect(mergePairs(inputs)).toEqual(expected); + }, + ); + + it('uses deterministic type order for equal scores', () => { + expect(mergePairs([ + [['tern', 0.8]], + [['cod', 0.8]], + ])).toEqual([ + ['cod', 0.8], + ['tern', 0.8], + ]); + }); + + it('returns independent arrays and tuples without changing its inputs', () => { + const first: [string, number][] = [['fish', 0.7]]; + const second: [string, number][] = [['shark', 1.0]]; + const before = [first.map((pair) => [...pair]), second.map((pair) => [...pair])]; + + const result = mergePairs([first, second]); + + expect([first, second]).toEqual(before); + expect(result).not.toBe(first); + result.forEach((pair) => { + expect(first).not.toContain(pair); + expect(second).not.toContain(pair); + }); + }); +}); diff --git a/client/dive-common/typeHierarchy.ts b/client/dive-common/typeHierarchy.ts index 3dc4c761c..9d13cb3bc 100644 --- a/client/dive-common/typeHierarchy.ts +++ b/client/dive-common/typeHierarchy.ts @@ -327,6 +327,24 @@ export function removePair( .map(([pairType, confidence]) => [pairType, confidence]); } +// Track merge combines stored evidence without invoking assignment or acceptance behavior. +// Ties use code-point order so the result does not depend on track or camera iteration order. +export function mergePairs( + pairLists: readonly (readonly (readonly [string, number])[])[], +): [string, number][] { + const confidenceByType = new Map(); + pairLists.forEach((pairs) => { + pairs.forEach(([type, confidence]) => { + const current = confidenceByType.get(type); + if (current === undefined || confidence > current) { + confidenceByType.set(type, confidence); + } + }); + }); + return Array.from(confidenceByType.entries()) + .sort((left, right) => (right[1] - left[1]) || codePointCompare(left[0], right[0])); +} + 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 13c10390a..a626948b2 100644 --- a/client/dive-common/use/useModeManager.spec.ts +++ b/client/dive-common/use/useModeManager.spec.ts @@ -13,6 +13,7 @@ import { IDENTITY3 } from 'vue-media-annotator/alignedView/alignedView'; import type { Matrix3 } from 'vue-media-annotator/alignedView/homography'; import type { AggregateMediaController } from 'vue-media-annotator/components/annotators/mediaControllerType'; import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; +import type { MarkChangesPending } from 'vue-media-annotator/BaseAnnotationStore'; import Track from 'vue-media-annotator/track'; import { ROTATION_ATTRIBUTE_NAME } from 'vue-media-annotator/utils'; import useModeManager from './useModeManager'; @@ -21,8 +22,8 @@ function translation(tx: number, ty: number): Matrix3 { return [[1, 0, tx], [0, 1, ty], [0, 0, 1]]; } -function makeHarness() { - const cameraStore = new CameraStore({ markChangesPending: () => undefined }); +function makeHarness(markChangesPending: MarkChangesPending = () => undefined) { + const cameraStore = new CameraStore({ markChangesPending }); cameraStore.removeCamera('singleCam'); cameraStore.addCamera('left'); cameraStore.addCamera('right'); @@ -236,6 +237,110 @@ describe('useModeManager counterpart creation', () => { }); }); +describe('useModeManager multicamera merge', () => { + it('canonicalizes every target and source replica before removing sources', () => { + const changes: string[] = []; + const { cameraStore, modeManager } = makeHarness((change) => { + changes.push(`${change.action}:${change.track?.id}`); + }); + const leftStore = cameraStore.camMap.value.get('left')?.trackStore; + const rightStore = cameraStore.camMap.value.get('right')?.trackStore; + leftStore?.insert(new Track(1, { + confidencePairs: [['fish', 0.4]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }), { imported: true }); + leftStore?.insert(Track.fromJSON({ + id: 2, + begin: 1, + end: 1, + attributes: {}, + confidencePairs: [['fish', 0.7]], + features: [{ frame: 1, bounds: [1, 1, 2, 2], keyframe: true }], + }), { imported: true }); + leftStore?.insert(Track.fromJSON({ + id: 3, + begin: 2, + end: 2, + attributes: {}, + confidencePairs: [['turtle', 0.8]], + features: [{ frame: 2, bounds: [2, 2, 3, 3], keyframe: true }], + }), { imported: true }); + rightStore?.insert(new Track(1, { + confidencePairs: [['rock', 0.6]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }), { imported: true }); + rightStore?.insert(Track.fromJSON({ + id: 2, + begin: 1, + end: 1, + attributes: {}, + confidencePairs: [['shark', 0.9]], + features: [{ frame: 1, bounds: [1, 1, 2, 2], keyframe: true }], + }), { imported: true }); + const leftTarget = cameraStore.getTrack(1, 'left'); + const setConfidencePairs = leftTarget.setConfidencePairs.bind(leftTarget); + vi.spyOn(leftTarget, 'setConfidencePairs').mockImplementation((pairs) => { + changes.push('canonical:left'); + setConfidencePairs(pairs); + }); + const rightTarget = cameraStore.getTrack(1, 'right'); + const setRightConfidencePairs = rightTarget.setConfidencePairs.bind(rightTarget); + vi.spyOn(rightTarget, 'setConfidencePairs').mockImplementation((pairs) => { + changes.push('canonical:right'); + setRightConfidencePairs(pairs); + }); + modeManager.multiSelectList.value = [1, 2, 3]; + + modeManager.handler.commitMerge(); + + const leftPairs = cameraStore.getTrack(1, 'left').confidencePairs; + const rightPairs = cameraStore.getTrack(1, 'right').confidencePairs; + expect(leftPairs).toEqual([ + ['shark', 0.9], ['turtle', 0.8], ['fish', 0.7], ['rock', 0.6], + ]); + expect(rightPairs).toEqual(leftPairs); + expect(rightPairs).not.toBe(leftPairs); + expect(cameraStore.getPossibleTrack(2, 'left')).toBeUndefined(); + expect(cameraStore.getPossibleTrack(2, 'right')).toBeUndefined(); + expect(cameraStore.getPossibleTrack(3, 'left')).toBeUndefined(); + ['canonical:left', 'canonical:right'].forEach((canonical) => { + expect(changes.indexOf(canonical)).toBeLessThan(changes.indexOf('delete:2')); + expect(changes.indexOf(canonical)).toBeLessThan(changes.indexOf('delete:3')); + }); + }); + + it('creates a target replica in a source-only camera without losing local data', () => { + const { cameraStore, modeManager } = makeHarness(); + const leftStore = cameraStore.camMap.value.get('left')?.trackStore; + const rightStore = cameraStore.camMap.value.get('right')?.trackStore; + leftStore?.insert(Track.fromJSON({ + id: 2, + begin: 4, + end: 4, + attributes: { camera: 'left' }, + confidencePairs: [['fish', 0.8]], + features: [{ frame: 4, bounds: [4, 5, 6, 7], keyframe: true }], + }), { imported: true }); + rightStore?.insert(new Track(1, { + confidencePairs: [['shark', 0.9]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + }), { imported: true }); + modeManager.multiSelectList.value = [1, 2]; + + modeManager.handler.commitMerge(); + + const leftTarget = cameraStore.getTrack(1, 'left'); + const rightTarget = cameraStore.getTrack(1, 'right'); + expect(leftTarget.features[4]?.bounds).toEqual([4, 5, 6, 7]); + expect(leftTarget.attributes).toEqual({ camera: 'left' }); + expect(leftTarget.confidencePairs).toEqual([['shark', 0.9], ['fish', 0.8]]); + expect(rightTarget.confidencePairs).toEqual(leftTarget.confidencePairs); + expect(rightTarget.confidencePairs).not.toBe(leftTarget.confidencePairs); + expect(rightTarget.confidencePairs[0]).not.toBe(leftTarget.confidencePairs[0]); + expect(cameraStore.getPossibleTrack(2, 'left')).toBeUndefined(); + }); +}); + describe('TrackFilterControls construction', () => { it('provides complete stored-track enumeration for hierarchy renames', () => { const { cameraStore, trackFilterControls } = makeSingleCamHarness(); diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 69b9d0f60..52ad7fa87 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -1334,14 +1334,12 @@ export default function useModeManager({ */ function handleCommitMerge() { if (multiSelectList.value.length >= 2) { - const track = cameraStore.getTrack(multiSelectList.value[0], selectedCamera.value); + const targetTrackId = multiSelectList.value[0]; const otherTrackIds = multiSelectList.value.slice(1); - track.merge(otherTrackIds.map( - (trackId) => cameraStore.getTrack(trackId, selectedCamera.value), - )); + cameraStore.mergeTracks(targetTrackId, otherTrackIds); handleRemoveTrack(otherTrackIds, true); handleToggleMerge(); - handleSelectTrack(track.id, false); + handleSelectTrack(targetTrackId, false); } } diff --git a/client/src/BaseFilterControls.ts b/client/src/BaseFilterControls.ts index 3e727280a..9947360cc 100644 --- a/client/src/BaseFilterControls.ts +++ b/client/src/BaseFilterControls.ts @@ -218,9 +218,14 @@ export default abstract class BaseFilterControls { } removeTypeAnnotations(types: string[]) { + const processedIds = new Set(); this.filteredAnnotations.value.forEach((filtered) => { + if (processedIds.has(filtered.annotation.id)) { + return; + } + processedIds.add(filtered.annotation.id); const filteredType = filtered.annotation.getType(filtered.context.confidencePairIndex); - if (filteredType && types.includes(filteredType[0])) { + if (filteredType && types.includes(filteredType)) { //Remove the type from the annotation if multiple types exist const newConfidencePairs = this.removeTypes(filtered.annotation.id, types); if (newConfidencePairs.length === 0) { diff --git a/client/src/CameraStore.spec.ts b/client/src/CameraStore.spec.ts index 827b2ecae..2b069cb41 100644 --- a/client/src/CameraStore.spec.ts +++ b/client/src/CameraStore.spec.ts @@ -1,6 +1,7 @@ /// import { compileHierarchy } from 'dive-common/typeHierarchy'; -import CameraStore from './CameraStore'; +import CameraStore, { formatDivergentClassificationWarning } from './CameraStore'; +import Group from './Group'; import Track, { Feature } from './track'; const HIERARCHY_INDEX = compileHierarchy({ @@ -127,12 +128,187 @@ describe('CameraStore classification commands', () => { const result = fixture.store.removeTrackPair(TRACK_ID, 'shark'); expectSynchronizedWrite(fixture, result, [ + ['rock', 0.95], ['fish', 0.9], ['great white shark', 0.4], ['bird', 0.2], ]); }); + it('removes types from the complete logical vector and synchronizes every camera', () => { + const fixture = makeTwoCameraStore(); + const result = fixture.store.removeTypes(TRACK_ID, ['shark', 'bird']); + + expectSynchronizedWrite(fixture, result, [ + ['rock', 0.95], + ['fish', 0.9], + ['great white shark', 0.4], + ]); + }); + + it('produces the same removal result for either camera insertion order', () => { + const removeFish = (cameraOrder: string[]) => { + const store = new CameraStore({ markChangesPending: vi.fn() }); + store.removeCamera('singleCam'); + cameraOrder.forEach((cameraName) => store.addCamera(cameraName)); + const vectors: Record = { + left: [['fish', 0.9]], + right: [['rock', 0.8]], + }; + cameraOrder.forEach((cameraName) => { + store.camMap.value.get(cameraName)?.trackStore.insert(new Track(TRACK_ID, { + confidencePairs: confidencePairs(vectors[cameraName]), + features: features(), + }), { imported: true }); + }); + const result = store.removeTypes(TRACK_ID, ['fish']); + expect(store.getTrackAll(TRACK_ID).map((track) => track.confidencePairs)) + .toEqual(cameraOrder.map(() => [['rock', 0.8]])); + return result; + }; + + expect(removeFish(['left', 'right'])).toEqual([['rock', 0.8]]); + expect(removeFish(['right', 'left'])).toEqual([['rock', 0.8]]); + }); + + it('cleans group membership when an empty classification deletes the logical track', () => { + const fixture = makeTwoCameraStore(); + fixture.left.setConfidencePairs([['fish', 0.9]]); + fixture.right.setConfidencePairs([['rock', 0.9]]); + fixture.store.camMap.value.forEach(({ groupStore }) => { + groupStore.insert(new Group(3, { + members: { + [TRACK_ID]: { ranges: [[0, 0]] }, + 99: { ranges: [[0, 0]] }, + }, + }), { imported: true }); + }); + fixture.markChangesPending.mockClear(); + + const result = fixture.store.removeTypes(TRACK_ID, ['fish', 'rock']); + expect(result).toEqual([]); + + fixture.store.camMap.value.forEach(({ trackStore, groupStore }) => { + expect(trackStore.getPossible(TRACK_ID)).toBeUndefined(); + expect(groupStore.get(3).memberIds).toEqual([99]); + expect(groupStore.trackMap.get(TRACK_ID)).toEqual(new Set()); + }); + expect(fixture.markChangesPending.mock.calls.filter(([change]) => change.action === 'delete')) + .toHaveLength(2); + }); + + it('cleans stale group membership in a camera without a track replica', () => { + const fixture = makeTwoCameraStore(); + fixture.left.setConfidencePairs([['fish', 0.9]]); + fixture.store.camMap.value.forEach(({ groupStore }) => { + groupStore.insert(new Group(3, { + members: { + [TRACK_ID]: { ranges: [[0, 0]] }, + 99: { ranges: [[0, 0]] }, + }, + }), { imported: true }); + }); + fixture.store.camMap.value.get('right')?.trackStore.remove(TRACK_ID, true); + fixture.markChangesPending.mockClear(); + + expect(fixture.store.removeTrackPair(TRACK_ID, 'fish')).toEqual([]); + + fixture.store.camMap.value.forEach(({ trackStore, groupStore }) => { + expect(trackStore.getPossible(TRACK_ID)).toBeUndefined(); + expect(groupStore.get(3).memberIds).toEqual([99]); + expect(groupStore.trackMap.get(TRACK_ID)).toEqual(new Set()); + }); + expect(fixture.markChangesPending.mock.calls.filter(([change]) => change.action === 'delete')) + .toHaveLength(1); + }); + + it('deletes a final pair through the single-pair command', () => { + const fixture = makeTwoCameraStore(); + fixture.left.setConfidencePairs([['fish', 0.9]]); + fixture.right.setConfidencePairs([['fish', 0.9]]); + fixture.markChangesPending.mockClear(); + + expect(fixture.store.removeTrackPair(TRACK_ID, 'fish')).toEqual([]); + expect(fixture.store.getTrackAll(TRACK_ID)).toEqual([]); + expect(fixture.markChangesPending.mock.calls.map(([change]) => change.action)) + .toEqual(['delete', 'delete']); + }); + + it('does not notify replicas for a no-op classification command', () => { + const fixture = makeTwoCameraStore(); + fixture.right.setConfidencePairs(fixture.left.confidencePairs); + fixture.markChangesPending.mockClear(); + + expect(fixture.store.removeTrackPair(TRACK_ID, 'not-present')).toEqual([ + ['fish', 0.9], + ['shark', 0.7], + ['great white shark', 0.4], + ['bird', 0.2], + ]); + expect(fixture.markChangesPending).not.toHaveBeenCalled(); + }); + + it('keeps group commands separate from colliding track ids across cameras', () => { + const fixture = makeTwoCameraStore(); + fixture.store.camMap.value.forEach(({ trackStore, groupStore }) => { + trackStore.insert(new Track(3, { + confidencePairs: [['track-type', 1]], + features: features(), + }), { imported: true }); + groupStore.insert(new Group(3, { + confidencePairs: [['group-type', 0.5]], + members: {}, + }), { imported: true }); + }); + fixture.markChangesPending.mockClear(); + + fixture.store.setGroupType(3, 'renamed-group', 0.7, 'group-type'); + expect(fixture.store.removeGroupTypes(3, ['renamed-group'])).toEqual([]); + + fixture.store.camMap.value.forEach(({ trackStore, groupStore }) => { + expect(trackStore.get(3).confidencePairs).toEqual([['track-type', 1]]); + expect(groupStore.get(3).confidencePairs).toEqual([]); + }); + expect(fixture.markChangesPending).toHaveBeenCalledTimes(4); + }); + + it('uses the first configured camera for any-track reads', () => { + const fixture = makeTwoCameraStore(); + + expect(fixture.store.getAnyTrack(TRACK_ID)).toBe(fixture.left); + expect(fixture.store.getAnyPossibleTrack(TRACK_ID)).toBe(fixture.left); + }); + + it('resets canonical camera order when a new dataset reverses existing cameras', () => { + const fixture = makeTwoCameraStore(); + expect(fixture.store.getAnyTrack(TRACK_ID)).toBe(fixture.left); + + fixture.store.clearAll(); + fixture.store.setCameraOrder(['right', 'left']); + const reloadedRight = new Track(TRACK_ID, { + confidencePairs: [['new-right', 1]], + features: features(), + }); + const reloadedLeft = new Track(TRACK_ID, { + confidencePairs: [['new-left', 1]], + features: features(), + }); + fixture.store.camMap.value.get('right')?.trackStore.insert(reloadedRight, { imported: true }); + fixture.store.camMap.value.get('left')?.trackStore.insert(reloadedLeft, { imported: true }); + + expect(Array.from(fixture.store.camMap.value.keys())).toEqual(['right', 'left']); + expect(fixture.store.getAnyTrack(TRACK_ID)).toBe(reloadedRight); + expect(fixture.store.getTrackProjection(TRACK_ID).confidencePairs).toEqual([['new-right', 1]]); + }); + + it('returns unknown when an imported track has no confidence pairs', () => { + const fixture = makeTwoCameraStore(); + fixture.left.setConfidencePairs([]); + fixture.right.setConfidencePairs([]); + + expect(fixture.store.getTrackProjectionForSorted(TRACK_ID).getType()).toBe('unknown'); + }); + it('renames one pair without applying assignment or acceptance semantics', () => { const fixture = makeTwoCameraStore(); const result = fixture.store.renameTrackPair(TRACK_ID, 'shark', 'selachimorpha'); @@ -246,7 +422,7 @@ describe('CameraStore track projections', () => { 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]]); + expect(projection.confidencePairs).toEqual([['fish', 0.7]]); mutationKeys.forEach((key) => expect(key in projection).toBe(false)); expect(left.serialize()).toEqual(leftBefore); expect(right.serialize()).toEqual(rightBefore); @@ -325,6 +501,49 @@ describe('CameraStore projection cache', () => { expect(after).not.toBe(before); expect(after.confidencePairs).toEqual(confidencePairs([['rock', 0.95], ['shark', 0.1]])); }); + + it('re-projects from the new canonical camera after a reorder', () => { + const { store } = makeTwoCameraStore(); + const before = store.getTrackProjection(TRACK_ID); + store.setCameraOrder(['right', '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 classification divergence', () => { + it('finds exact vector differences only when an id exists in multiple cameras', () => { + const store = new CameraStore({ markChangesPending: vi.fn() }); + store.removeCamera('singleCam'); + store.addCamera('left'); + store.addCamera('right'); + const insert = (cameraName: string, id: number, pairs: [string, number][]) => { + store.camMap.value.get(cameraName)?.trackStore.insert(new Track(id, { + confidencePairs: confidencePairs(pairs), + features: features(), + }), { imported: true }); + }; + insert('left', 9, [['fish', 0.8], ['bird', 0.2]]); + insert('right', 9, [['fish', 0.8], ['bird', 0.2]]); + insert('left', 4, [['fish', 0.8], ['bird', 0.2]]); + insert('right', 4, [['bird', 0.2], ['fish', 0.8]]); + insert('left', 2, [['fish', 0.8]]); + insert('right', 2, [['fish', 0.7]]); + insert('left', 1, [['left only', 1]]); + + expect(store.divergentClassificationTrackIds()).toEqual([2, 4]); + }); + + it('formats one bounded, sorted dataset warning', () => { + expect(formatDivergentClassificationWarning([])).toBeNull(); + expect(formatDivergentClassificationWarning([5, 2])).toBe( + '2 tracks have divergent per-camera classifications (tracks 2, 5)', + ); + expect(formatDivergentClassificationWarning([11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1])).toBe( + '11 tracks have divergent per-camera classifications (tracks 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, …)', + ); + }); }); describe('CameraStore track editor commands', () => { diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts index 1b2157a2d..ca1e1dd8f 100644 --- a/client/src/CameraStore.ts +++ b/client/src/CameraStore.ts @@ -1,16 +1,17 @@ import { ComputedRef, Ref, computed, shallowRef, triggerRef, } from 'vue'; -import { uniq } from 'lodash'; +import { cloneDeep, uniq } from 'lodash'; import { acceptPairAsCorrect, compileHierarchy, + mergePairs, reassignPairs, removePair, setPairConfidence, TypeHierarchyIndex, } from 'dive-common/typeHierarchy'; -import type Track from './track'; +import Track from './track'; import type Group from './Group'; import { AnnotationId, ConfidencePair } from './BaseAnnotation'; import { MarkChangesPending, SortedAnnotation } from './BaseAnnotationStore'; @@ -26,6 +27,28 @@ interface TrackAssignmentOptions { confidence?: number; } +function confidencePairsEqual( + left: readonly ConfidencePair[], + right: readonly ConfidencePair[], +): boolean { + return left.length === right.length + && left.every(([type, confidence], index) => ( + type === right[index][0] && confidence === right[index][1] + )); +} + +export function formatDivergentClassificationWarning( + trackIds: readonly AnnotationId[], +): string | null { + if (trackIds.length === 0) { + return null; + } + const sortedIds = [...trackIds].sort((a, b) => a - b); + const shownIds = sortedIds.slice(0, 10).join(', '); + const suffix = sortedIds.length > 10 ? ', …' : ''; + return `${sortedIds.length} tracks have divergent per-camera classifications (tracks ${shownIds}${suffix})`; +} + /** * 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. @@ -97,27 +120,14 @@ export default class CameraStore { } getAnyPossibleTrack(trackId: Readonly) { - let track: Track | undefined; - this.camMap.value.forEach((camera) => { - const tempTrack = camera.trackStore.getPossible(trackId); - if (tempTrack) { - track = tempTrack; - } - }); - if (track) { - return track; - } - return undefined; + // Map iteration order defines the canonical camera for logical-track reads. + return Array.from(this.camMap.value.values()) + .map((camera) => camera.trackStore.getPossible(trackId)) + .find((track): track is Track => track !== undefined); } getAnyTrack(trackId: Readonly) { - let track: Track | undefined; - this.camMap.value.forEach((camera) => { - const tempTrack = camera.trackStore.getPossible(trackId); - if (tempTrack) { - track = tempTrack; - } - }); + const track = this.getAnyPossibleTrack(trackId); if (track) { return track; } @@ -136,6 +146,23 @@ export default class CameraStore { return trackList; } + divergentClassificationTrackIds(): AnnotationId[] { + const vectorsByTrack = new Map(); + this.camMap.value.forEach(({ trackStore }) => { + trackStore.annotationIds.value.forEach((trackId) => { + const track = trackStore.get(trackId); + const vectors = vectorsByTrack.get(trackId) || []; + vectors.push(track.confidencePairs); + vectorsByTrack.set(trackId, vectors); + }); + }); + return Array.from(vectorsByTrack.entries()) + .filter(([, vectors]) => vectors.length > 1 + && vectors.slice(1).some((vector) => !confidencePairsEqual(vectors[0], vector))) + .map(([trackId]) => trackId) + .sort((a, b) => a - b); + } + /** * 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, @@ -175,17 +202,24 @@ export default class CameraStore { return projection; } + /** + * The sorted list is recomputed on every annotation mutation, so it reads only the + * logical range and classification rather than building a full TrackProjection, whose + * per-feature deep copies would scale with the whole dataset on each edit. Safe only + * once projections take classification from the canonical camera instead of merging. + */ 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 + const tracks = this.getTrackAll(trackId); + if (tracks.length === 0) { + throw Error(`TrackId: ${trackId} is not found in any camera`); + } + const confidencePairs = tracks[0].confidencePairs .map(([type, confidence]) => [type, confidence] as ConfidencePair); return { - id: track.id, + id: tracks[0].id, confidencePairs, - begin: track.begin, - end: track.end, + begin: Math.min(...tracks.map((track) => track.begin)), + end: Math.max(...tracks.map((track) => track.end)), getType: (index?: number) => (confidencePairs[index || 0]?.[0] || 'unknown'), }; } @@ -201,6 +235,23 @@ export default class CameraStore { } } + setCameraOrder(cameraNames: readonly string[]) { + cameraNames.forEach((cameraName) => this.addCamera(cameraName)); + const reordered = new Map(); + cameraNames.forEach((cameraName) => { + const camera = this.camMap.value.get(cameraName); + if (camera !== undefined) { + reordered.set(cameraName, camera); + } + }); + this.camMap.value.forEach((camera, cameraName) => { + if (!reordered.has(cameraName)) { + reordered.set(cameraName, camera); + } + }); + this.camMap.value = reordered; + } + removeCamera(cameraName: string) { if (this.camMap.value.get(cameraName) !== undefined) { this.camMap.value.delete(cameraName); @@ -224,18 +275,62 @@ export default class CameraStore { remove(trackId: AnnotationId, cameraName = '') { this.camMap.value.forEach((camera) => { - if (camera.trackStore.getPossible(trackId)) { - if (cameraName === '' || camera.trackStore.cameraName === cameraName) { - camera.trackStore.remove(trackId); - } - if (cameraName === '' || camera.groupStore.cameraName === cameraName) { - camera.groupStore.trackRemove(trackId); - } + if ( + camera.trackStore.getPossible(trackId) + && (cameraName === '' || camera.trackStore.cameraName === cameraName) + ) { + camera.trackStore.remove(trackId); + } + if (cameraName === '' || camera.groupStore.cameraName === cameraName) { + camera.groupStore.trackRemove(trackId); } }); this.projectionCache.delete(trackId); } + mergeTracks(targetId: AnnotationId, sourceIds: AnnotationId[]) { + const replicas: Array<{ + trackStore: TrackStore; + target?: Track; + sources: Track[]; + }> = []; + const vectors: ConfidencePair[][] = []; + + this.camMap.value.forEach(({ trackStore }) => { + const target = trackStore.getPossible(targetId); + const sources = sourceIds + .map((sourceId) => trackStore.getPossible(sourceId)) + .filter((source): source is Track => source !== undefined); + if (target || sources.length) { + replicas.push({ trackStore, target, sources }); + if (target) { + vectors.push(target.confidencePairs); + } + sources.forEach((source) => vectors.push(source.confidencePairs)); + } + }); + + const canonicalPairs = mergePairs(vectors); + replicas.forEach((replica) => { + let { target } = replica; + if (!target) { + const source = replica.sources[0]; + target = Track.fromJSON({ + id: targetId, + begin: source.begin, + end: source.end, + confidencePairs: cloneDeep(source.confidencePairs), + attributes: cloneDeep(source.attributes), + features: cloneDeep(source.features.filter((feature) => feature !== undefined)), + meta: cloneDeep(source.meta), + }, source.set); + replica.trackStore.insert(target); + } + target.merge(replica.sources); + target.setConfidencePairs(canonicalPairs); + }); + } + getNewTrackId() { let trackIds: number[] = []; this.camMap.value.forEach((camera) => { @@ -256,13 +351,7 @@ export default class CameraStore { } removeTracks(id: AnnotationId, cameraName = '') { - this.camMap.value.forEach((camera) => { - if (camera.trackStore.getPossible(id)) { - if (cameraName === '' || camera.trackStore.cameraName === cameraName) { - camera.trackStore.remove(id); - } - } - }); + this.remove(id, cameraName); } removeGroups(id: AnnotationId, cameraName = '') { @@ -285,18 +374,41 @@ export default class CameraStore { }); } + setGroupType(id: AnnotationId, newType: string, confidenceVal?: number, currentType?: string) { + this.camMap.value.forEach((camera) => { + const group = camera.groupStore.getPossible(id); + if (group !== undefined) { + group.setType(newType, confidenceVal, currentType); + } + }); + } + private updateTrackConfidencePairs( id: AnnotationId, update: (pairs: readonly ConfidencePair[]) => ConfidencePair[], + mergeReplicaPairs = false, + deleteWhenEmpty = false, ): 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); + // Merging re-sorts equal-confidence pairs into a canonical order, which would move the + // displayed type of a single-replica track that never needed reconciling. + const canonicalPairs = mergeReplicaPairs && tracks.length > 1 + ? mergePairs(tracks.map((track) => track.confidencePairs)) + : tracks[0].confidencePairs + .map(([type, confidence]) => [type, confidence] as ConfidencePair); const nextPairs = update(canonicalPairs); - tracks.forEach((track) => track.setConfidencePairs(nextPairs)); + if (deleteWhenEmpty && nextPairs.length === 0) { + this.remove(id); + return []; + } + tracks.forEach((track) => { + if (!confidencePairsEqual(track.confidencePairs, nextPairs)) { + track.setConfidencePairs(nextPairs); + } + }); return nextPairs.map(([type, confidence]) => [type, confidence]); } @@ -341,7 +453,7 @@ export default class CameraStore { } removeTrackPair(id: AnnotationId, type: string): ConfidencePair[] { - return this.updateTrackConfidencePairs(id, (pairs) => removePair(pairs, type)); + return this.updateTrackConfidencePairs(id, (pairs) => removePair(pairs, type), true, true); } renameTrackPair( @@ -430,15 +542,33 @@ export default class CameraStore { this.getTrackForCameraEdit(id, cameraName)?.toggleInterpolationForAllGaps(frame); } - removeTypes(id: AnnotationId, types: string[]) { - let resultingTypes: ConfidencePair[] = []; + removeTypes(id: AnnotationId, types: string[]): ConfidencePair[] { + const removedTypes = new Set(types); + return this.updateTrackConfidencePairs( + id, + (pairs) => pairs + .filter(([type]) => !removedTypes.has(type)) + .map(([type, confidence]) => [type, confidence] as ConfidencePair), + true, + true, + ); + } + + removeGroupTypes(id: AnnotationId, types: string[]): ConfidencePair[] { + let result: ConfidencePair[] | undefined; this.camMap.value.forEach((camera) => { - const track = camera.trackStore.getPossible(id); - if (track !== undefined) { - resultingTypes = track.removeTypes(types); + const group = camera.groupStore.getPossible(id); + if (group !== undefined) { + const pairs = group.removeTypes(types); + if (result === undefined) { + result = pairs.map(([type, confidence]) => [type, confidence] as ConfidencePair); + } } }); - return resultingTypes; + if (result === undefined) { + throw new Error(`GroupId ${id} not found in any camera`); + } + return result; } getGroupMemebers(id: AnnotationId) { diff --git a/client/src/TrackFilterControls.spec.ts b/client/src/TrackFilterControls.spec.ts index ec7d6f750..5b7a0829a 100644 --- a/client/src/TrackFilterControls.spec.ts +++ b/client/src/TrackFilterControls.spec.ts @@ -208,6 +208,20 @@ describe('useAnnotationFilters', () => { expect(tf.consumeLoadWarning()).not.toBeNull(); }); + it('queues each dataset load warning once and resets the channel on a new load', () => { + const tf = makeTrackFilterControls(); + const warning = '2 tracks have divergent per-camera classifications (tracks 2, 5)'; + tf.queueLoadWarning(warning); + tf.queueLoadWarning(warning); + + expect(tf.consumeLoadWarning()).toBe(warning); + expect(tf.consumeLoadWarning()).toBeNull(); + + tf.setTypeHierarchy(undefined); + tf.queueLoadWarning(warning); + expect(tf.consumeLoadWarning()).toBe(warning); + }); + it('retains a hierarchy save patch until persistence succeeds', () => { const tf = makeTrackFilterControls(); tf.setTypeHierarchy({ foo: 'root' }); @@ -393,12 +407,16 @@ describe('useAnnotationFilters', () => { expect(tf.confidenceFilters.value).toEqual({ baz: 0.1 }); }); - it('removeTypeTrack', async () => { - const tf = makeTrackFilterControls(); - tf.removeTypeAnnotations(['bar']); - expect(tf.allTypes.value).toEqual(['foo', 'bar', 'baz']); - tf.removeTypeAnnotations(['baz']); - expect(tf.allTypes.value).toEqual(['foo', 'bar', 'baz']); + it('removes annotations by the complete displayed type name', () => { + const { cameraStore, filters } = makePairFixture([ + [['bar', 1]], + [['bar', 0.8], ['baz', 0.7]], + ]); + + filters.removeTypeAnnotations(['bar']); + + expect(cameraStore.getPossibleTrack(0)).toBeUndefined(); + expect(cameraStore.getTrack(1).confidencePairs).toEqual([['baz', 0.7]]); }); it('returns the caller fallback without recomputing flat pair selection', () => { @@ -696,7 +714,7 @@ describe('useAnnotationFilters', () => { expect(markPending).toHaveBeenCalledTimes(2); }); - it('blocks deleting a type that only a collapse-hidden camera still uses', () => { + it('blocks deleting a type that a divergent camera still uses', () => { const markPending = vi.fn(); const { cameraStore, filters } = makePairFixture([ [['fish', 0.9], ['tuna', 0.7]], @@ -706,18 +724,17 @@ describe('useAnnotationFilters', () => { confidencePairs: [['shark', 1]], features, })); - filters.importTypes(['tuna'], false); - filters.setConfidenceFilters({ tuna: 0.4, default: 0.1 }); - filters.setTypeHierarchy({ tuna: 'fish' }); + filters.importTypes(['shark'], false); + filters.setConfidenceFilters({ shark: 0.4, default: 0.1 }); + filters.setTypeHierarchy({ shark: 'fish' }); markPending.mockClear(); - expect(filters.usedTypes.value).toEqual(['shark']); - expect(filters.typeInUseOnAnyCamera('tuna')).toBe(true); - expect(filters.deleteType('tuna')).toBe(false); - expect(filters.typeHierarchy.value).toEqual({ tuna: 'fish' }); - expect(filters.configuredTypes.value).toContain('tuna'); - expect(filters.confidenceFilters.value).toHaveProperty('tuna', 0.4); - expect(filters.checkedTypes.value).toContain('tuna'); + expect(filters.usedTypes.value).toEqual(['fish', 'tuna']); + expect(filters.typeInUseOnAnyCamera('shark')).toBe(true); + expect(filters.deleteType('shark')).toBe(false); + expect(filters.typeHierarchy.value).toEqual({ shark: 'fish' }); + expect(filters.configuredTypes.value).toContain('shark'); + expect(filters.confidenceFilters.value).toHaveProperty('shark', 0.4); expect(markPending).not.toHaveBeenCalled(); }); diff --git a/client/src/TrackFilterControls.ts b/client/src/TrackFilterControls.ts index e47e9a2a4..95f725053 100644 --- a/client/src/TrackFilterControls.ts +++ b/client/src/TrackFilterControls.ts @@ -51,7 +51,7 @@ export default class TrackFilterControls extends BaseFilterControls { invalidHierarchyReason: Ref; - private hierarchyWarningConsumed = false; + private pendingLoadWarnings: string[] = []; private hierarchyDirty = false; @@ -246,8 +246,13 @@ export default class TrackFilterControls extends BaseFilterControls { /** Install hierarchy state loaded from a dataset or a successful config replacement. */ setTypeHierarchy(value: unknown) { - this.hierarchyWarningConsumed = false; + this.pendingLoadWarnings = []; this.installTypeHierarchy(value, false); + if (this.invalidHierarchyReason.value !== null) { + this.queueLoadWarning( + `The saved type hierarchy is invalid: ${this.invalidHierarchyReason.value}. Hierarchical type selection is disabled until the configuration is corrected.`, + ); + } } /** Usage across every camera's stored vector, unlike the lossy merged `usedTypes`. */ @@ -339,12 +344,14 @@ export default class TrackFilterControls extends BaseFilterControls { return true; } - consumeLoadWarning(): string | null { - if (this.invalidHierarchyReason.value === null || this.hierarchyWarningConsumed) { - return null; + queueLoadWarning(message: string): void { + if (!this.pendingLoadWarnings.includes(message)) { + this.pendingLoadWarnings.push(message); } - this.hierarchyWarningConsumed = true; - return `The saved type hierarchy is invalid: ${this.invalidHierarchyReason.value}. Hierarchical type selection is disabled until the configuration is corrected.`; + } + + consumeLoadWarning(): string | null { + return this.pendingLoadWarnings.shift() || null; } typeHierarchySavePatch(): TypeHierarchySavePatch { diff --git a/client/src/TrackProjection.ts b/client/src/TrackProjection.ts index ffe6299cb..c07661fde 100644 --- a/client/src/TrackProjection.ts +++ b/client/src/TrackProjection.ts @@ -48,24 +48,10 @@ export function createTrackProjection(tracks: readonly Track[]): TrackProjection } const features: (Feature | undefined)[] = []; - let confidencePairs = first.confidencePairs + const confidencePairs = first.confidencePairs .map(([type, confidence]) => [type, confidence] as ConfidencePair); const attributes = cloneDeep(first.attributes); - 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]); - } - } - }); - } + tracks.forEach((track) => { track.features.forEach((feature) => { if (features[feature.frame] === undefined) { features[feature.frame] = cloneDeep(feature); diff --git a/client/src/index.ts b/client/src/index.ts index 04af74bea..b22fd3006 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -5,7 +5,7 @@ import * as components from './components'; import AlignedViewStore from './alignedView/AlignedViewStore'; import BaseAnnotation from './BaseAnnotation'; import BaseAnnotationStore from './BaseAnnotationStore'; -import CameraStore from './CameraStore'; +import CameraStore, { formatDivergentClassificationWarning } from './CameraStore'; import CameraRegistrationStore from './alignedView/CameraRegistrationStore'; import Group from './Group'; import GroupFilterControls from './GroupFilterControls'; @@ -31,6 +31,7 @@ export { BaseAnnotation, BaseAnnotationStore, CameraStore, + formatDivergentClassificationWarning, CameraRegistrationStore, Group, GroupFilterControls, diff --git a/client/src/provides.ts b/client/src/provides.ts index e0f8a7823..5cefbb0d6 100644 --- a/client/src/provides.ts +++ b/client/src/provides.ts @@ -353,13 +353,22 @@ function dummyState(): State { cameraStore.setTrackType(id, newType, confidenceVal, currentType); }; const removeTypes = (id: AnnotationId, types: string[]) => cameraStore.removeTypes(id, types); + const setGroupType = ( + id: AnnotationId, + newType: string, + confidenceVal?: number, + currentType?: string, + ) => { + cameraStore.setGroupType(id, newType, confidenceVal, currentType); + }; + const removeGroupTypes = (id: AnnotationId, types: string[]) => cameraStore.removeGroupTypes(id, types); const groupFilterControls = new GroupFilterControls( { sorted: cameraStore.sortedGroups, remove: cameraStore.removeGroups, markChangesPending, - setType: setTrackType, - removeTypes, + setType: setGroupType, + removeTypes: removeGroupTypes, }, ); const trackFilterControls = new TrackFilterControls({ diff --git a/client/src/track.spec.ts b/client/src/track.spec.ts index c1bef87ea..5f7fbfced 100644 --- a/client/src/track.spec.ts +++ b/client/src/track.spec.ts @@ -203,6 +203,56 @@ describe('Track', () => { expect(track0.featureIndex.length).toBe(3); expect(track0.confidencePairs).toEqual([['c', 0.3], ['a', 0.2], ['b', 0.2]]); }); + + it('merges a score-one pair without dropping other classifications', () => { + const receiver = new Track(1, { + confidencePairs: [['b', 0.4], ['a', 0.2]], + }); + const source = new Track(2, { + confidencePairs: [['a', 1.0], ['c', 0.7]], + }); + const sourceBefore = source.confidencePairs.map((pair) => [...pair]); + + receiver.merge([source]); + + expect(receiver.confidencePairs).toEqual([ + ['a', 1.0], + ['c', 0.7], + ['b', 0.4], + ]); + expect(source.confidencePairs).toEqual(sourceBefore); + receiver.confidencePairs.forEach((pair) => expect(source.confidencePairs).not.toContain(pair)); + }); + + it('splits classifications into independent vectors and tuples', () => { + const source = Track.fromJSON({ + id: 1, + begin: 0, + end: 10, + attributes: {}, + confidencePairs: [['a', 0.8], ['b', 0.2]], + features: [ + { frame: 0, bounds: [0, 0, 1, 1] }, + { frame: 10, bounds: [10, 10, 1, 1] }, + ], + }); + + const [left, right] = source.split(5, 2, 3); + + expect(left.confidencePairs).toEqual(source.confidencePairs); + expect(right.confidencePairs).toEqual(source.confidencePairs); + expect(left.confidencePairs).not.toBe(source.confidencePairs); + expect(right.confidencePairs).not.toBe(source.confidencePairs); + expect(left.confidencePairs).not.toBe(right.confidencePairs); + left.confidencePairs.forEach((pair) => { + expect(source.confidencePairs).not.toContain(pair); + expect(right.confidencePairs).not.toContain(pair); + }); + + left.setType('a', 0.4); + expect(source.confidencePairs).toEqual([['a', 0.8], ['b', 0.2]]); + expect(right.confidencePairs).toEqual([['a', 0.8], ['b', 0.2]]); + }); it('toggleInterpolation(frame) and toggleKeyframe(frame)', () => { const itrack: TrackData = { attributes: {}, diff --git a/client/src/track.ts b/client/src/track.ts index 1ad6fb688..6fed2683f 100644 --- a/client/src/track.ts +++ b/client/src/track.ts @@ -1,3 +1,4 @@ +import { mergePairs } from 'dive-common/typeHierarchy'; import { RectBounds, polygonEqualsBounds } from './utils'; import { binarySearch, @@ -179,7 +180,8 @@ export default class Track extends BaseAnnotation { begin: this.begin, end: this.getPreviousKeyframe(frame - 1) || this.begin, features: this.features.slice(this.begin, frame), - confidencePairs: this.confidencePairs, + confidencePairs: this.confidencePairs + .map(([type, confidence]) => [type, confidence] as [string, number]), attributes: this.attributes, }), Track.fromJSON({ @@ -188,7 +190,8 @@ export default class Track extends BaseAnnotation { begin: this.getNextKeyframe(frame) || this.end, end: this.end, features: this.features.slice(frame), - confidencePairs: this.confidencePairs, + confidencePairs: this.confidencePairs + .map(([type, confidence]) => [type, confidence] as [string, number]), attributes: this.attributes, }), ]; @@ -199,14 +202,20 @@ export default class Track extends BaseAnnotation { * self if there are conflicts */ merge(others: Track[]) { + const previousPairs = this.confidencePairs; + const mergedPairs = mergePairs([ + this.confidencePairs, + ...others.map((other) => other.confidencePairs), + ]); + const pairsChanged = mergedPairs.length !== previousPairs.length + || mergedPairs.some(([type, confidence], index) => ( + previousPairs[index]?.[0] !== type || previousPairs[index]?.[1] !== confidence + )); + if (pairsChanged) { + this.confidencePairs = mergedPairs; + this.notify('confidencePairs', previousPairs); + } others.forEach((other) => { - other.confidencePairs.forEach((pair) => { - const match = this.confidencePairs.find(([name]) => name === pair[0]); - // Only set confidence if greater - if (match === undefined || match[1] < pair[1]) { - this.setType(...pair); - } - }); other.features.forEach((f) => { if (this.getFeature(f.frame)[0] === null) { this.setFeature(f, f.geometry?.features); diff --git a/docs/UI-Type-List.md b/docs/UI-Type-List.md index 91b8de354..2b418f6e8 100644 --- a/docs/UI-Type-List.md +++ b/docs/UI-Type-List.md @@ -30,6 +30,8 @@ Assigning a type to a track is hierarchy-aware. A type together with its ancesto 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. +Linked multicamera tracks are expected to store identical confidence-pair vectors. If existing camera replicas differ, DIVE reports one warning when the dataset loads and uses the first camera in configured display order for the read-only track projection; it does not union classifications while merging display geometry. Removing classifications evaluates the complete logical vector and synchronizes the result across replicas. + 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.